From 348f911852254c8358a89a0cfed2f1eb8d1dccd3 Mon Sep 17 00:00:00 2001 From: StarInk Date: Sun, 9 Feb 2025 08:23:13 +0200 Subject: [PATCH 001/110] =?UTF-8?q?=F0=9F=92=BE=20Feat:=20Workflow=20UI=20?= =?UTF-8?q?Base?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Config/AnnouncementConfig.json | 7 ++ Config/AppConfig.json | 143 +++++++++++++++++++++++++++++++++ Config/MarketConfig.json | 9 +++ Config/PluginsConfig.json | 7 ++ Config/SecurityConfig.json | 7 ++ KitX Clients/KitX Dashboard | 2 +- KitX SDK | 2 +- KitX Standard | 2 +- 8 files changed, 176 insertions(+), 3 deletions(-) create mode 100644 Config/AnnouncementConfig.json create mode 100644 Config/AppConfig.json create mode 100644 Config/MarketConfig.json create mode 100644 Config/PluginsConfig.json create mode 100644 Config/SecurityConfig.json diff --git a/Config/AnnouncementConfig.json b/Config/AnnouncementConfig.json new file mode 100644 index 00000000..09d933d2 --- /dev/null +++ b/Config/AnnouncementConfig.json @@ -0,0 +1,7 @@ +{ + "$type": "AnnouncementConfig", + "Accepted": [], + "ConfigFileLocation": "G:\\Dev\\KitX\\Config\\AnnouncementConfig.json", + "ConfigFileWatcherName": null, + "ConfigGeneratedTime": "2024-09-15T15:03:50.5253031+03:00" +} \ No newline at end of file diff --git a/Config/AppConfig.json b/Config/AppConfig.json new file mode 100644 index 00000000..87b0dc6c --- /dev/null +++ b/Config/AppConfig.json @@ -0,0 +1,143 @@ +{ + "$type": "AppConfig", + "App": { + "IconFileName": "KitX-Icon-1920x-margin-2x.png", + "CoverIconFileName": "KitX-Icon-Background.png", + "AppLanguage": "zh-cn", + "Theme": "Follow", + "ThemeColor": "#FF3873D9", + "SurpportLanguages": { + "zh-cn": "\u4E2D\u6587 (\u7B80\u4F53)", + "zh-tw": "\u4E2D\u6587 (\u7E41\u9AD4)", + "ru-ru": "\u0420\u0443\u0441\u0441\u043A\u0438\u0439", + "en-us": "English (US)", + "fr-fr": "Fran\u00E7ais", + "ja-jp": "\u65E5\u672C\u8A9E", + "ko-kr": "\uD55C\uAD6D\uC5B4" + }, + "LocalPluginsFileFolder": "./Plugins/", + "LocalPluginsDataFolder": "./PluginsDatas/", + "DeveloperSetting": false, + "ShowAnnouncementWhenStart": true, + "RanTime": 0, + "LastBreakAfterExit": 2000 + }, + "Windows": { + "MainWindow": { + "Size": { + "Width": 1280, + "Height": 720, + "FramePerSecond": null, + "Area": 921600, + "AspectRatio": 1.7777777777777777, + "Description": null + }, + "Location": { + "Left": -1, + "Right": 0, + "Top": -1, + "Bottom": 0, + "Over": 0, + "Under": 0 + }, + "WindowState": 0, + "IsHidden": false, + "Tags": { + "SelectedPage": "Page_Home" + }, + "EnabledMica": true, + "GreetingTextCount_Morning": 5, + "GreetingTextCount_Noon": 3, + "GreetingTextCount_AfterNoon": 3, + "GreetingTextCount_Evening": 2, + "GreetingTextCount_Night": 4, + "GreetingUpdateInterval": 10 + }, + "AnnouncementWindow": { + "Size": { + "Width": 1280, + "Height": 720, + "FramePerSecond": null, + "Area": 921600, + "AspectRatio": 1.7777777777777777, + "Description": null + }, + "Location": { + "Left": -1, + "Right": 0, + "Top": -1, + "Bottom": 0, + "Over": 0, + "Under": 0 + } + } + }, + "Pages": { + "Home": { + "NavigationViewPaneDisplayMode": 0, + "SelectedViewName": "View_Recent", + "IsNavigationViewPaneOpened": true, + "UseAreaExpanded": true + }, + "Device": {}, + "Market": {}, + "Settings": { + "NavigationViewPaneDisplayMode": 0, + "SelectedViewName": "View_General", + "PaletteAreaExpanded": false, + "WebRelatedAreaExpanded": true, + "WebRelatedAreaOfNetworkInterfacesExpanded": false, + "LogRelatedAreaExpanded": true, + "UpdateRelatedAreaExpanded": true, + "AboutAreaExpanded": false, + "AuthorsAreaExpanded": false, + "LinksAreaExpanded": false, + "ThirdPartyLicensesAreaExpanded": false, + "IsNavigationViewPaneOpened": true + } + }, + "Web": { + "DelayStartSeconds": 0.5, + "ApiServer": "api.catrol.cn", + "ApiPath": "/apps/kitx/", + "DevicesViewRefreshDelay": 1000, + "AcceptedNetworkInterfaces": null, + "UserSpecifiedDevicesServerPort": null, + "UserSpecifiedPluginsServerPort": null, + "UdpPortSend": 23404, + "UdpPortReceive": 24040, + "UdpSendFrequency": 1000, + "UdpBroadcastAddress": "224.0.0.0", + "IPFilter": "192.168", + "SocketBufferSize": 102400, + "DeviceInfoTTLSeconds": 7, + "DisableRemovingOfflineDeviceCard": false, + "UpdateServer": "api.catrol.cn", + "UpdatePath": "/apps/kitx/%platform%/", + "UpdateDownloadPath": "/apps/kitx/update/%platform%/", + "UpdateChannel": "stable", + "UpdateSource": "latest-components.json", + "DebugServicesServerPort": 7777 + }, + "Log": { + "LogFileSingleMaxSize": 10485760, + "LogFilePath": "./Log/", + "LogTemplate": "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] {Message:lj}{NewLine}{Exception}", + "LogFileMaxCount": 50, + "LogFileFlushInterval": 30, + "LogLevel": 2 + }, + "IO": { + "UpdatingCheckPerThreadFilesCount": 20, + "OperatingSystemVersionUpdateInterval": 60 + }, + "Activity": { + "TotalRecorded": 0 + }, + "Loaders": { + "InstallPath": "./Loaders/" + }, + "ConfigFileLocation": "G:\\Dev\\KitX\\Config\\AppConfig.json", + "ConfigFileWatcherName": null, + "ConfigGeneratedTime": "2024-09-15T15:03:50.4380847+03:00" +} \ No newline at end of file diff --git a/Config/MarketConfig.json b/Config/MarketConfig.json new file mode 100644 index 00000000..dd55f6c4 --- /dev/null +++ b/Config/MarketConfig.json @@ -0,0 +1,9 @@ +{ + "$type": "MarketConfig", + "Sources": { + "KitX Official Market Source": "https://cget.catrol.cn/KitX/v1/index.json" + }, + "ConfigFileLocation": "G:\\Dev\\KitX\\Config\\MarketConfig.json", + "ConfigFileWatcherName": null, + "ConfigGeneratedTime": "2024-09-15T15:03:50.5230006+03:00" +} \ No newline at end of file diff --git a/Config/PluginsConfig.json b/Config/PluginsConfig.json new file mode 100644 index 00000000..3ae98ef1 --- /dev/null +++ b/Config/PluginsConfig.json @@ -0,0 +1,7 @@ +{ + "$type": "PluginsConfig", + "Plugins": [], + "ConfigFileLocation": "G:\\Dev\\KitX\\Config\\PluginsConfig.json", + "ConfigFileWatcherName": null, + "ConfigGeneratedTime": "2024-09-15T15:03:50.5186505+03:00" +} \ No newline at end of file diff --git a/Config/SecurityConfig.json b/Config/SecurityConfig.json new file mode 100644 index 00000000..23d36988 --- /dev/null +++ b/Config/SecurityConfig.json @@ -0,0 +1,7 @@ +{ + "$type": "SecurityConfig", + "DeviceKeys": [], + "ConfigFileLocation": "G:\\Dev\\KitX\\Config\\SecurityConfig.json", + "ConfigFileWatcherName": null, + "ConfigGeneratedTime": "2024-09-15T15:03:50.5276407+03:00" +} \ No newline at end of file diff --git a/KitX Clients/KitX Dashboard b/KitX Clients/KitX Dashboard index 9560c213..5b173878 160000 --- a/KitX Clients/KitX Dashboard +++ b/KitX Clients/KitX Dashboard @@ -1 +1 @@ -Subproject commit 9560c21376820b16574450be019fdd4da462ade1 +Subproject commit 5b173878f53d0326b62551b501ff7e2083eefbda diff --git a/KitX SDK b/KitX SDK index d7ba74fb..67feebed 160000 --- a/KitX SDK +++ b/KitX SDK @@ -1 +1 @@ -Subproject commit d7ba74fb1729ee8ed060012a6797498601c69551 +Subproject commit 67feebed01e8fa11bd98bc4face88a88c6da7f5b diff --git a/KitX Standard b/KitX Standard index c7524d0f..e6a78609 160000 --- a/KitX Standard +++ b/KitX Standard @@ -1 +1 @@ -Subproject commit c7524d0f8745c9f404e4ef1e45e581816ef21c80 +Subproject commit e6a78609b07733e42c932f081d2b01ae6df5571e From c9a3a6dad6d6363ca2a1c702a607fff2534062af Mon Sep 17 00:00:00 2001 From: Dynesshely Date: Tue, 31 Dec 2024 00:11:11 +0800 Subject: [PATCH 002/110] =?UTF-8?q?=F0=9F=93=9D=20Chore:=20Sync=20submodul?= =?UTF-8?q?es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- KitX Clients/KitX Website | 2 +- KitX SDK | 2 +- KitX Standard | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/KitX Clients/KitX Website b/KitX Clients/KitX Website index 9c695af3..31e9aa91 160000 --- a/KitX Clients/KitX Website +++ b/KitX Clients/KitX Website @@ -1 +1 @@ -Subproject commit 9c695af3e90d42c804bba5ceb9d87222dd4418bb +Subproject commit 31e9aa917fa529ae4582375c1c6fe53bbdba5b74 diff --git a/KitX SDK b/KitX SDK index 67feebed..f82e010f 160000 --- a/KitX SDK +++ b/KitX SDK @@ -1 +1 @@ -Subproject commit 67feebed01e8fa11bd98bc4face88a88c6da7f5b +Subproject commit f82e010fd3b7c7726122179fb346c2e98ecce9ea diff --git a/KitX Standard b/KitX Standard index e6a78609..bd9a2c0b 160000 --- a/KitX Standard +++ b/KitX Standard @@ -1 +1 @@ -Subproject commit e6a78609b07733e42c932f081d2b01ae6df5571e +Subproject commit bd9a2c0b577deb894be87bc307a2fc8dbe02105a From 0af2aa40f1ee8f8712fd539e62158bf3586ea79e Mon Sep 17 00:00:00 2001 From: Dynesshely Date: Tue, 31 Dec 2024 00:42:34 +0800 Subject: [PATCH 003/110] =?UTF-8?q?=F0=9F=93=84=20Docs:=20Better=20version?= =?UTF-8?q?s=20list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.md b/README.md index 94bf0e62..d037c9f2 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,11 @@ cheese setup --reference
+
+Not Finished Versions + +
+ | Version | Info | Code | Support | Term | Require | Runs on | |-----------------------------------------------------------------------------------|---------|-----------|--------------------|--------------------|---------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | [v3.0.6187.47831](https://github.com/Crequency/KitX/releases/tag/v3.0.6187.47831) | Preview | Fly | :x: | 2022.04 -> 2023.04 | `Desktop`: .Net 6 (Also Self-Contained)
`Mobile`: Native | ![Windows](https://img.shields.io/badge/Windows-0078D6?logo=windows&logoColor=white) ![Linux](https://img.shields.io/badge/Linux-FCC624?logo=linux&logoColor=black) ![MacOS](https://img.shields.io/badge/mac%20os-000000?logo=macos&logoColor=F0F0F0) | @@ -186,6 +191,13 @@ cheese setup --reference | [v3.22.04.6235](https://github.com/Crequency/KitX/releases/tag/v3.22.04.6235) | Release | Break | :x: | 2022.04 -> 2023.04 | `Desktop`: .Net 6 (Also Self-Contained)
`Mobile`: Native | ![Windows](https://img.shields.io/badge/Windows-0078D6?logo=windows&logoColor=white) ![Linux](https://img.shields.io/badge/Linux-FCC624?logo=linux&logoColor=black) ![MacOS](https://img.shields.io/badge/mac%20os-000000?logo=macos&logoColor=F0F0F0) | | [v3.22.04.6287](https://github.com/Crequency/KitX/releases/tag/v3.22.04.6287) | Release | Evolution | :x: | 2022.04 -> 2023.04 | `Desktop`: .Net 6 (Also Self-Contained)
`Mobile`: Native | ![Windows](https://img.shields.io/badge/Windows-0078D6?logo=windows&logoColor=white) ![Linux](https://img.shields.io/badge/Linux-FCC624?logo=linux&logoColor=black) ![MacOS](https://img.shields.io/badge/mac%20os-000000?logo=macos&logoColor=F0F0F0) | | [v3.23.04.6488](https://github.com/Crequency/KitX/releases/tag/v3.23.04.6488) | Release | ToYou | :white_check_mark: | 2023.04 -> 2024.04 | `Desktop`: .Net 6 (Also Self-Contained)
`Mobile`: Native | ![Windows](https://img.shields.io/badge/Windows-0078D6?logo=windows&logoColor=white) ![Linux](https://img.shields.io/badge/Linux-FCC624?logo=linux&logoColor=black) ![MacOS](https://img.shields.io/badge/mac%20os-000000?logo=macos&logoColor=F0F0F0) ![Android](https://img.shields.io/badge/Android-3DDC84?logo=android&logoColor=white) ![Raspberry Pi](https://img.shields.io/badge/-RaspberryPi-C51A4A?logo=Raspberry-Pi) | + +
+ +
+ +| Version | Info | Code | Support | Term | Require | Runs on | +|-----------------------------------------------------------------------------------|---------|-----------|--------------------|--------------------|---------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | v3.24.10.x | Release | - | developing | 2024.10 -> 2025.04 | `Desktop`: .Net 8 (Also Self-Contained)
`Mobile`: Native | ![Windows](https://img.shields.io/badge/Windows-0078D6?logo=windows&logoColor=white) ![Linux](https://img.shields.io/badge/Linux-FCC624?logo=linux&logoColor=black) ![MacOS](https://img.shields.io/badge/mac%20os-000000?logo=macos&logoColor=F0F0F0) ![Android](https://img.shields.io/badge/Android-3DDC84?logo=android&logoColor=white) ![iOS](https://img.shields.io/badge/iOS-000000?logo=ios&logoColor=white) ![Raspberry Pi](https://img.shields.io/badge/-RaspberryPi-C51A4A?logo=Raspberry-Pi) | See details in [ChangeLog](./ChangeLog.md) From f98d3511a12b499db6eec2d310b421a8d009615d Mon Sep 17 00:00:00 2001 From: Dynesshely Date: Wed, 1 Jan 2025 22:29:43 +0800 Subject: [PATCH 004/110] =?UTF-8?q?=F0=9F=93=84=20Docs:=20Updated=20target?= =?UTF-8?q?=20version?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d037c9f2..fbc4bad2 100644 --- a/README.md +++ b/README.md @@ -198,7 +198,7 @@ cheese setup --reference | Version | Info | Code | Support | Term | Require | Runs on | |-----------------------------------------------------------------------------------|---------|-----------|--------------------|--------------------|---------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| v3.24.10.x | Release | - | developing | 2024.10 -> 2025.04 | `Desktop`: .Net 8 (Also Self-Contained)
`Mobile`: Native | ![Windows](https://img.shields.io/badge/Windows-0078D6?logo=windows&logoColor=white) ![Linux](https://img.shields.io/badge/Linux-FCC624?logo=linux&logoColor=black) ![MacOS](https://img.shields.io/badge/mac%20os-000000?logo=macos&logoColor=F0F0F0) ![Android](https://img.shields.io/badge/Android-3DDC84?logo=android&logoColor=white) ![iOS](https://img.shields.io/badge/iOS-000000?logo=ios&logoColor=white) ![Raspberry Pi](https://img.shields.io/badge/-RaspberryPi-C51A4A?logo=Raspberry-Pi) | +| v3.25.04.x | Release | - | developing | 2024.10 -> 2025.04 | `Desktop`: .Net 8 (Also Self-Contained)
`Mobile`: Native | ![Windows](https://img.shields.io/badge/Windows-0078D6?logo=windows&logoColor=white) ![Linux](https://img.shields.io/badge/Linux-FCC624?logo=linux&logoColor=black) ![MacOS](https://img.shields.io/badge/mac%20os-000000?logo=macos&logoColor=F0F0F0) ![Android](https://img.shields.io/badge/Android-3DDC84?logo=android&logoColor=white) ![iOS](https://img.shields.io/badge/iOS-000000?logo=ios&logoColor=white) ![Raspberry Pi](https://img.shields.io/badge/-RaspberryPi-C51A4A?logo=Raspberry-Pi) | See details in [ChangeLog](./ChangeLog.md) From b3a5278141e02508fceac6669dc3faf87cf6fb2d Mon Sep 17 00:00:00 2001 From: Dynesshely Date: Fri, 10 Jan 2025 03:07:27 +0800 Subject: [PATCH 005/110] =?UTF-8?q?=F0=9F=92=BE=20=F0=9F=8E=87=20Feat,=20S?= =?UTF-8?q?tyle(Dashboard):=20All-round=20optimization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Separate platform specific operations out - Fixed some typo errors --- KitX.sln | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/KitX.sln b/KitX.sln index 61f3013c..be8221ba 100644 --- a/KitX.sln +++ b/KitX.sln @@ -133,6 +133,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SyncCodes", "KitX SDK\Utils EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KitX.Cheese", ".cheese\KitX.Cheese.csproj", "{4FF6F614-F88E-4621-9A30-F6A75873218D}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Specific", "Specific", "{B2234097-D037-4126-9CC0-F7ECF3FEEEA5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KitX.Dashboard.Specific.MsWindows", "KitX Clients\KitX Dashboard\Specific\KitX Dashboard Specific MsWindows\KitX.Dashboard.Specific.MsWindows.csproj", "{91F13EBC-53CE-4032-AAFC-268E90C32E5F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -727,6 +731,26 @@ Global {4FF6F614-F88E-4621-9A30-F6A75873218D}.Release|x64.Build.0 = Release|Any CPU {4FF6F614-F88E-4621-9A30-F6A75873218D}.Release|x86.ActiveCfg = Release|Any CPU {4FF6F614-F88E-4621-9A30-F6A75873218D}.Release|x86.Build.0 = Release|Any CPU + {91F13EBC-53CE-4032-AAFC-268E90C32E5F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {91F13EBC-53CE-4032-AAFC-268E90C32E5F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {91F13EBC-53CE-4032-AAFC-268E90C32E5F}.Debug|ARM.ActiveCfg = Debug|Any CPU + {91F13EBC-53CE-4032-AAFC-268E90C32E5F}.Debug|ARM.Build.0 = Debug|Any CPU + {91F13EBC-53CE-4032-AAFC-268E90C32E5F}.Debug|ARM64.ActiveCfg = Debug|Any CPU + {91F13EBC-53CE-4032-AAFC-268E90C32E5F}.Debug|ARM64.Build.0 = Debug|Any CPU + {91F13EBC-53CE-4032-AAFC-268E90C32E5F}.Debug|x64.ActiveCfg = Debug|Any CPU + {91F13EBC-53CE-4032-AAFC-268E90C32E5F}.Debug|x64.Build.0 = Debug|Any CPU + {91F13EBC-53CE-4032-AAFC-268E90C32E5F}.Debug|x86.ActiveCfg = Debug|Any CPU + {91F13EBC-53CE-4032-AAFC-268E90C32E5F}.Debug|x86.Build.0 = Debug|Any CPU + {91F13EBC-53CE-4032-AAFC-268E90C32E5F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {91F13EBC-53CE-4032-AAFC-268E90C32E5F}.Release|Any CPU.Build.0 = Release|Any CPU + {91F13EBC-53CE-4032-AAFC-268E90C32E5F}.Release|ARM.ActiveCfg = Release|Any CPU + {91F13EBC-53CE-4032-AAFC-268E90C32E5F}.Release|ARM.Build.0 = Release|Any CPU + {91F13EBC-53CE-4032-AAFC-268E90C32E5F}.Release|ARM64.ActiveCfg = Release|Any CPU + {91F13EBC-53CE-4032-AAFC-268E90C32E5F}.Release|ARM64.Build.0 = Release|Any CPU + {91F13EBC-53CE-4032-AAFC-268E90C32E5F}.Release|x64.ActiveCfg = Release|Any CPU + {91F13EBC-53CE-4032-AAFC-268E90C32E5F}.Release|x64.Build.0 = Release|Any CPU + {91F13EBC-53CE-4032-AAFC-268E90C32E5F}.Release|x86.ActiveCfg = Release|Any CPU + {91F13EBC-53CE-4032-AAFC-268E90C32E5F}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -771,6 +795,8 @@ Global {DD575EF3-DF09-40A1-A89A-AB775D65A423} = {FF0F9B42-7FFA-4113-AD0E-15955D89BFEA} {2E7D2566-EAFF-4921-A312-88CF0ABC40E6} = {69E9C41D-1848-4F9F-8C9C-0CAC1C167336} {4D8ED1CF-E2FF-4666-88D5-7D5300014301} = {2E7D2566-EAFF-4921-A312-88CF0ABC40E6} + {B2234097-D037-4126-9CC0-F7ECF3FEEEA5} = {0194E969-1457-4C16-AFAB-D2753DFA07F0} + {91F13EBC-53CE-4032-AAFC-268E90C32E5F} = {B2234097-D037-4126-9CC0-F7ECF3FEEEA5} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {09BBC227-F41B-4D10-9E38-0EEE07ED17BC} From ae004750335ed32c71483b27e73adef6e4ba9f2e Mon Sep 17 00:00:00 2001 From: StarInk Date: Sun, 9 Feb 2025 09:27:16 +0200 Subject: [PATCH 006/110] =?UTF-8?q?=F0=9F=93=9D=20Chore:=20Update=20submod?= =?UTF-8?q?ule=20commits=20for=20Dashboard,=20Website,=20SDK,=20and=20Stan?= =?UTF-8?q?dard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- KitX Clients/KitX Dashboard | 2 +- KitX Clients/KitX Website | 2 +- KitX SDK | 2 +- KitX Standard | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/KitX Clients/KitX Dashboard b/KitX Clients/KitX Dashboard index 5b173878..a0cd2720 160000 --- a/KitX Clients/KitX Dashboard +++ b/KitX Clients/KitX Dashboard @@ -1 +1 @@ -Subproject commit 5b173878f53d0326b62551b501ff7e2083eefbda +Subproject commit a0cd272047818978e3b64739e115b52e49fd65a2 diff --git a/KitX Clients/KitX Website b/KitX Clients/KitX Website index 31e9aa91..9c695af3 160000 --- a/KitX Clients/KitX Website +++ b/KitX Clients/KitX Website @@ -1 +1 @@ -Subproject commit 31e9aa917fa529ae4582375c1c6fe53bbdba5b74 +Subproject commit 9c695af3e90d42c804bba5ceb9d87222dd4418bb diff --git a/KitX SDK b/KitX SDK index f82e010f..67feebed 160000 --- a/KitX SDK +++ b/KitX SDK @@ -1 +1 @@ -Subproject commit f82e010fd3b7c7726122179fb346c2e98ecce9ea +Subproject commit 67feebed01e8fa11bd98bc4face88a88c6da7f5b diff --git a/KitX Standard b/KitX Standard index bd9a2c0b..b8262901 160000 --- a/KitX Standard +++ b/KitX Standard @@ -1 +1 @@ -Subproject commit bd9a2c0b577deb894be87bc307a2fc8dbe02105a +Subproject commit b8262901a0869f8531db739241ac0e81721f89c4 From 36b5172b87a1d541cc386ccd12d0ea41abebbaa3 Mon Sep 17 00:00:00 2001 From: StarInk Date: Sat, 15 Mar 2025 06:56:22 +0200 Subject: [PATCH 007/110] =?UTF-8?q?=F0=9F=93=9D=20Chore:=20Update=20submod?= =?UTF-8?q?ule=20commits=20and=20add=20Kscript.CSharp=20project=20to=20sol?= =?UTF-8?q?ution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- KitX Clients/KitX Dashboard | 2 +- KitX Standard | 2 +- KitX.sln | 23 +++++++++++++++++++++++ 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/KitX Clients/KitX Dashboard b/KitX Clients/KitX Dashboard index a0cd2720..7a5bd479 160000 --- a/KitX Clients/KitX Dashboard +++ b/KitX Clients/KitX Dashboard @@ -1 +1 @@ -Subproject commit a0cd272047818978e3b64739e115b52e49fd65a2 +Subproject commit 7a5bd479ebacc9abb8381edeaebc3dc9f9fcb51f diff --git a/KitX Standard b/KitX Standard index b8262901..d72cb557 160000 --- a/KitX Standard +++ b/KitX Standard @@ -1 +1 @@ -Subproject commit b8262901a0869f8531db739241ac0e81721f89c4 +Subproject commit d72cb557019b56461dc31e9e492cfb9a47840383 diff --git a/KitX.sln b/KitX.sln index be8221ba..3e61dfd3 100644 --- a/KitX.sln +++ b/KitX.sln @@ -137,6 +137,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Specific", "Specific", "{B2 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KitX.Dashboard.Specific.MsWindows", "KitX Clients\KitX Dashboard\Specific\KitX Dashboard Specific MsWindows\KitX.Dashboard.Specific.MsWindows.csproj", "{91F13EBC-53CE-4032-AAFC-268E90C32E5F}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Kscript.CSharp", "KitX Standard\KitX Script\Kscript.CSharp\Kscript.CSharp.csproj", "{1B22641F-6CC9-4F00-BDBE-07EA81E5CF8F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -751,6 +753,26 @@ Global {91F13EBC-53CE-4032-AAFC-268E90C32E5F}.Release|x64.Build.0 = Release|Any CPU {91F13EBC-53CE-4032-AAFC-268E90C32E5F}.Release|x86.ActiveCfg = Release|Any CPU {91F13EBC-53CE-4032-AAFC-268E90C32E5F}.Release|x86.Build.0 = Release|Any CPU + {1B22641F-6CC9-4F00-BDBE-07EA81E5CF8F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1B22641F-6CC9-4F00-BDBE-07EA81E5CF8F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1B22641F-6CC9-4F00-BDBE-07EA81E5CF8F}.Debug|ARM.ActiveCfg = Debug|Any CPU + {1B22641F-6CC9-4F00-BDBE-07EA81E5CF8F}.Debug|ARM.Build.0 = Debug|Any CPU + {1B22641F-6CC9-4F00-BDBE-07EA81E5CF8F}.Debug|ARM64.ActiveCfg = Debug|Any CPU + {1B22641F-6CC9-4F00-BDBE-07EA81E5CF8F}.Debug|ARM64.Build.0 = Debug|Any CPU + {1B22641F-6CC9-4F00-BDBE-07EA81E5CF8F}.Debug|x64.ActiveCfg = Debug|Any CPU + {1B22641F-6CC9-4F00-BDBE-07EA81E5CF8F}.Debug|x64.Build.0 = Debug|Any CPU + {1B22641F-6CC9-4F00-BDBE-07EA81E5CF8F}.Debug|x86.ActiveCfg = Debug|Any CPU + {1B22641F-6CC9-4F00-BDBE-07EA81E5CF8F}.Debug|x86.Build.0 = Debug|Any CPU + {1B22641F-6CC9-4F00-BDBE-07EA81E5CF8F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1B22641F-6CC9-4F00-BDBE-07EA81E5CF8F}.Release|Any CPU.Build.0 = Release|Any CPU + {1B22641F-6CC9-4F00-BDBE-07EA81E5CF8F}.Release|ARM.ActiveCfg = Release|Any CPU + {1B22641F-6CC9-4F00-BDBE-07EA81E5CF8F}.Release|ARM.Build.0 = Release|Any CPU + {1B22641F-6CC9-4F00-BDBE-07EA81E5CF8F}.Release|ARM64.ActiveCfg = Release|Any CPU + {1B22641F-6CC9-4F00-BDBE-07EA81E5CF8F}.Release|ARM64.Build.0 = Release|Any CPU + {1B22641F-6CC9-4F00-BDBE-07EA81E5CF8F}.Release|x64.ActiveCfg = Release|Any CPU + {1B22641F-6CC9-4F00-BDBE-07EA81E5CF8F}.Release|x64.Build.0 = Release|Any CPU + {1B22641F-6CC9-4F00-BDBE-07EA81E5CF8F}.Release|x86.ActiveCfg = Release|Any CPU + {1B22641F-6CC9-4F00-BDBE-07EA81E5CF8F}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -797,6 +819,7 @@ Global {4D8ED1CF-E2FF-4666-88D5-7D5300014301} = {2E7D2566-EAFF-4921-A312-88CF0ABC40E6} {B2234097-D037-4126-9CC0-F7ECF3FEEEA5} = {0194E969-1457-4C16-AFAB-D2753DFA07F0} {91F13EBC-53CE-4032-AAFC-268E90C32E5F} = {B2234097-D037-4126-9CC0-F7ECF3FEEEA5} + {1B22641F-6CC9-4F00-BDBE-07EA81E5CF8F} = {7C64F32B-E1EE-45F7-94A7-DA2AD9288249} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {09BBC227-F41B-4D10-9E38-0EEE07ED17BC} From c21ea289e8a6dfbc60d4ea7713899da45ba7b562 Mon Sep 17 00:00:00 2001 From: StarInk Date: Sun, 3 Aug 2025 22:05:47 +0800 Subject: [PATCH 008/110] =?UTF-8?q?=F0=9F=93=9D=20Chore:=20Update=20submod?= =?UTF-8?q?ule=20commits=20and=20add=20Kscript.CSharp.Parser=20project=20t?= =?UTF-8?q?o=20solution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- KitX Clients/KitX Dashboard | 2 +- KitX Clients/KitX Mobile | 2 +- KitX Clients/KitX Website | 2 +- KitX SDK | 2 +- KitX Standard | 2 +- KitX.sln | 23 +++++++++++++++++++++++ 6 files changed, 28 insertions(+), 5 deletions(-) diff --git a/KitX Clients/KitX Dashboard b/KitX Clients/KitX Dashboard index 7a5bd479..33daef53 160000 --- a/KitX Clients/KitX Dashboard +++ b/KitX Clients/KitX Dashboard @@ -1 +1 @@ -Subproject commit 7a5bd479ebacc9abb8381edeaebc3dc9f9fcb51f +Subproject commit 33daef53e45bf8ff9920630279709974aae3d1b8 diff --git a/KitX Clients/KitX Mobile b/KitX Clients/KitX Mobile index 50e19a6a..b3d1c19f 160000 --- a/KitX Clients/KitX Mobile +++ b/KitX Clients/KitX Mobile @@ -1 +1 @@ -Subproject commit 50e19a6a6d49f44d0161f4fa6e31ae87ef6e840c +Subproject commit b3d1c19f54bbacf6b1600f9f06cdc6ff5eb9646f diff --git a/KitX Clients/KitX Website b/KitX Clients/KitX Website index 9c695af3..0615941a 160000 --- a/KitX Clients/KitX Website +++ b/KitX Clients/KitX Website @@ -1 +1 @@ -Subproject commit 9c695af3e90d42c804bba5ceb9d87222dd4418bb +Subproject commit 0615941ac539ff115d93ca1245f736407e28e977 diff --git a/KitX SDK b/KitX SDK index 67feebed..1f4e437c 160000 --- a/KitX SDK +++ b/KitX SDK @@ -1 +1 @@ -Subproject commit 67feebed01e8fa11bd98bc4face88a88c6da7f5b +Subproject commit 1f4e437c10774363b4b3103d18085039519db644 diff --git a/KitX Standard b/KitX Standard index d72cb557..090432d8 160000 --- a/KitX Standard +++ b/KitX Standard @@ -1 +1 @@ -Subproject commit d72cb557019b56461dc31e9e492cfb9a47840383 +Subproject commit 090432d8f1b2de1ff1e9bc31a41a826d5b4b8ab9 diff --git a/KitX.sln b/KitX.sln index 3e61dfd3..69511a03 100644 --- a/KitX.sln +++ b/KitX.sln @@ -139,6 +139,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KitX.Dashboard.Specific.MsW EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Kscript.CSharp", "KitX Standard\KitX Script\Kscript.CSharp\Kscript.CSharp.csproj", "{1B22641F-6CC9-4F00-BDBE-07EA81E5CF8F}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Kscript.CSharp.Parser", "KitX Standard\KitX Script\Kscript.CSharp.Parser\Kscript.CSharp.Parser.csproj", "{0EE16A23-B6A2-0A99-D990-1CA6386B2351}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -773,6 +775,26 @@ Global {1B22641F-6CC9-4F00-BDBE-07EA81E5CF8F}.Release|x64.Build.0 = Release|Any CPU {1B22641F-6CC9-4F00-BDBE-07EA81E5CF8F}.Release|x86.ActiveCfg = Release|Any CPU {1B22641F-6CC9-4F00-BDBE-07EA81E5CF8F}.Release|x86.Build.0 = Release|Any CPU + {0EE16A23-B6A2-0A99-D990-1CA6386B2351}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0EE16A23-B6A2-0A99-D990-1CA6386B2351}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0EE16A23-B6A2-0A99-D990-1CA6386B2351}.Debug|ARM.ActiveCfg = Debug|Any CPU + {0EE16A23-B6A2-0A99-D990-1CA6386B2351}.Debug|ARM.Build.0 = Debug|Any CPU + {0EE16A23-B6A2-0A99-D990-1CA6386B2351}.Debug|ARM64.ActiveCfg = Debug|Any CPU + {0EE16A23-B6A2-0A99-D990-1CA6386B2351}.Debug|ARM64.Build.0 = Debug|Any CPU + {0EE16A23-B6A2-0A99-D990-1CA6386B2351}.Debug|x64.ActiveCfg = Debug|Any CPU + {0EE16A23-B6A2-0A99-D990-1CA6386B2351}.Debug|x64.Build.0 = Debug|Any CPU + {0EE16A23-B6A2-0A99-D990-1CA6386B2351}.Debug|x86.ActiveCfg = Debug|Any CPU + {0EE16A23-B6A2-0A99-D990-1CA6386B2351}.Debug|x86.Build.0 = Debug|Any CPU + {0EE16A23-B6A2-0A99-D990-1CA6386B2351}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0EE16A23-B6A2-0A99-D990-1CA6386B2351}.Release|Any CPU.Build.0 = Release|Any CPU + {0EE16A23-B6A2-0A99-D990-1CA6386B2351}.Release|ARM.ActiveCfg = Release|Any CPU + {0EE16A23-B6A2-0A99-D990-1CA6386B2351}.Release|ARM.Build.0 = Release|Any CPU + {0EE16A23-B6A2-0A99-D990-1CA6386B2351}.Release|ARM64.ActiveCfg = Release|Any CPU + {0EE16A23-B6A2-0A99-D990-1CA6386B2351}.Release|ARM64.Build.0 = Release|Any CPU + {0EE16A23-B6A2-0A99-D990-1CA6386B2351}.Release|x64.ActiveCfg = Release|Any CPU + {0EE16A23-B6A2-0A99-D990-1CA6386B2351}.Release|x64.Build.0 = Release|Any CPU + {0EE16A23-B6A2-0A99-D990-1CA6386B2351}.Release|x86.ActiveCfg = Release|Any CPU + {0EE16A23-B6A2-0A99-D990-1CA6386B2351}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -820,6 +842,7 @@ Global {B2234097-D037-4126-9CC0-F7ECF3FEEEA5} = {0194E969-1457-4C16-AFAB-D2753DFA07F0} {91F13EBC-53CE-4032-AAFC-268E90C32E5F} = {B2234097-D037-4126-9CC0-F7ECF3FEEEA5} {1B22641F-6CC9-4F00-BDBE-07EA81E5CF8F} = {7C64F32B-E1EE-45F7-94A7-DA2AD9288249} + {0EE16A23-B6A2-0A99-D990-1CA6386B2351} = {7C64F32B-E1EE-45F7-94A7-DA2AD9288249} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {09BBC227-F41B-4D10-9E38-0EEE07ED17BC} From dc20040692435a2f41fb50b11e75d8dc1f5b32df Mon Sep 17 00:00:00 2001 From: StarInk Date: Sun, 3 Aug 2025 22:06:06 +0800 Subject: [PATCH 009/110] =?UTF-8?q?=F0=9F=93=9D=20Chore:=20Update=20subpro?= =?UTF-8?q?ject=20commit=20for=20KitX=20Dashboard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- KitX Clients/KitX Dashboard | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/KitX Clients/KitX Dashboard b/KitX Clients/KitX Dashboard index 33daef53..d8c814bc 160000 --- a/KitX Clients/KitX Dashboard +++ b/KitX Clients/KitX Dashboard @@ -1 +1 @@ -Subproject commit 33daef53e45bf8ff9920630279709974aae3d1b8 +Subproject commit d8c814bc031254e8010e78e4c30d2bdb08cfaa0c From 8ee7249c5002d1da81afa3b8b39d76040063aee6 Mon Sep 17 00:00:00 2001 From: StarInk Date: Sat, 8 Nov 2025 10:38:56 +0100 Subject: [PATCH 010/110] =?UTF-8?q?=F0=9F=93=9D=20Chore:=20Update=20subpro?= =?UTF-8?q?ject=20commits=20and=20add=20Kscript.CSharp.Compiler=20and=20Ks?= =?UTF-8?q?cript.CSharp.Parser.Examples=20projects=20to=20solution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- KitX Clients/KitX Dashboard | 2 +- KitX Standard | 2 +- KitX.sln | 44 +++++++++++++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/KitX Clients/KitX Dashboard b/KitX Clients/KitX Dashboard index d8c814bc..c6b0c938 160000 --- a/KitX Clients/KitX Dashboard +++ b/KitX Clients/KitX Dashboard @@ -1 +1 @@ -Subproject commit d8c814bc031254e8010e78e4c30d2bdb08cfaa0c +Subproject commit c6b0c938540388443f9dcaa88d37cb09a50985f4 diff --git a/KitX Standard b/KitX Standard index 090432d8..e80de9d7 160000 --- a/KitX Standard +++ b/KitX Standard @@ -1 +1 @@ -Subproject commit 090432d8f1b2de1ff1e9bc31a41a826d5b4b8ab9 +Subproject commit e80de9d7837165b2b00dd23bba9307fba979da75 diff --git a/KitX.sln b/KitX.sln index 69511a03..0010b027 100644 --- a/KitX.sln +++ b/KitX.sln @@ -141,6 +141,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Kscript.CSharp", "KitX Stan EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Kscript.CSharp.Parser", "KitX Standard\KitX Script\Kscript.CSharp.Parser\Kscript.CSharp.Parser.csproj", "{0EE16A23-B6A2-0A99-D990-1CA6386B2351}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Kscript.CSharp.Compiler", "KitX Standard\KitX Script\Kscript.CSharp.Compiler\Kscript.CSharp.Compiler.csproj", "{CABC40F6-65E6-61C8-AC71-65C0D1DD9AD1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Kscript.CSharp.Parser.Examples", "KitX Standard\KitX Script\Kscript.CSharp.Parser.Examples\Kscript.CSharp.Parser.Examples.csproj", "{24D7FCC6-63D4-BACC-CADC-75A14B351A9F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -795,6 +799,46 @@ Global {0EE16A23-B6A2-0A99-D990-1CA6386B2351}.Release|x64.Build.0 = Release|Any CPU {0EE16A23-B6A2-0A99-D990-1CA6386B2351}.Release|x86.ActiveCfg = Release|Any CPU {0EE16A23-B6A2-0A99-D990-1CA6386B2351}.Release|x86.Build.0 = Release|Any CPU + {CABC40F6-65E6-61C8-AC71-65C0D1DD9AD1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CABC40F6-65E6-61C8-AC71-65C0D1DD9AD1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CABC40F6-65E6-61C8-AC71-65C0D1DD9AD1}.Debug|ARM.ActiveCfg = Debug|Any CPU + {CABC40F6-65E6-61C8-AC71-65C0D1DD9AD1}.Debug|ARM.Build.0 = Debug|Any CPU + {CABC40F6-65E6-61C8-AC71-65C0D1DD9AD1}.Debug|ARM64.ActiveCfg = Debug|Any CPU + {CABC40F6-65E6-61C8-AC71-65C0D1DD9AD1}.Debug|ARM64.Build.0 = Debug|Any CPU + {CABC40F6-65E6-61C8-AC71-65C0D1DD9AD1}.Debug|x64.ActiveCfg = Debug|Any CPU + {CABC40F6-65E6-61C8-AC71-65C0D1DD9AD1}.Debug|x64.Build.0 = Debug|Any CPU + {CABC40F6-65E6-61C8-AC71-65C0D1DD9AD1}.Debug|x86.ActiveCfg = Debug|Any CPU + {CABC40F6-65E6-61C8-AC71-65C0D1DD9AD1}.Debug|x86.Build.0 = Debug|Any CPU + {CABC40F6-65E6-61C8-AC71-65C0D1DD9AD1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CABC40F6-65E6-61C8-AC71-65C0D1DD9AD1}.Release|Any CPU.Build.0 = Release|Any CPU + {CABC40F6-65E6-61C8-AC71-65C0D1DD9AD1}.Release|ARM.ActiveCfg = Release|Any CPU + {CABC40F6-65E6-61C8-AC71-65C0D1DD9AD1}.Release|ARM.Build.0 = Release|Any CPU + {CABC40F6-65E6-61C8-AC71-65C0D1DD9AD1}.Release|ARM64.ActiveCfg = Release|Any CPU + {CABC40F6-65E6-61C8-AC71-65C0D1DD9AD1}.Release|ARM64.Build.0 = Release|Any CPU + {CABC40F6-65E6-61C8-AC71-65C0D1DD9AD1}.Release|x64.ActiveCfg = Release|Any CPU + {CABC40F6-65E6-61C8-AC71-65C0D1DD9AD1}.Release|x64.Build.0 = Release|Any CPU + {CABC40F6-65E6-61C8-AC71-65C0D1DD9AD1}.Release|x86.ActiveCfg = Release|Any CPU + {CABC40F6-65E6-61C8-AC71-65C0D1DD9AD1}.Release|x86.Build.0 = Release|Any CPU + {24D7FCC6-63D4-BACC-CADC-75A14B351A9F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {24D7FCC6-63D4-BACC-CADC-75A14B351A9F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {24D7FCC6-63D4-BACC-CADC-75A14B351A9F}.Debug|ARM.ActiveCfg = Debug|Any CPU + {24D7FCC6-63D4-BACC-CADC-75A14B351A9F}.Debug|ARM.Build.0 = Debug|Any CPU + {24D7FCC6-63D4-BACC-CADC-75A14B351A9F}.Debug|ARM64.ActiveCfg = Debug|Any CPU + {24D7FCC6-63D4-BACC-CADC-75A14B351A9F}.Debug|ARM64.Build.0 = Debug|Any CPU + {24D7FCC6-63D4-BACC-CADC-75A14B351A9F}.Debug|x64.ActiveCfg = Debug|Any CPU + {24D7FCC6-63D4-BACC-CADC-75A14B351A9F}.Debug|x64.Build.0 = Debug|Any CPU + {24D7FCC6-63D4-BACC-CADC-75A14B351A9F}.Debug|x86.ActiveCfg = Debug|Any CPU + {24D7FCC6-63D4-BACC-CADC-75A14B351A9F}.Debug|x86.Build.0 = Debug|Any CPU + {24D7FCC6-63D4-BACC-CADC-75A14B351A9F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {24D7FCC6-63D4-BACC-CADC-75A14B351A9F}.Release|Any CPU.Build.0 = Release|Any CPU + {24D7FCC6-63D4-BACC-CADC-75A14B351A9F}.Release|ARM.ActiveCfg = Release|Any CPU + {24D7FCC6-63D4-BACC-CADC-75A14B351A9F}.Release|ARM.Build.0 = Release|Any CPU + {24D7FCC6-63D4-BACC-CADC-75A14B351A9F}.Release|ARM64.ActiveCfg = Release|Any CPU + {24D7FCC6-63D4-BACC-CADC-75A14B351A9F}.Release|ARM64.Build.0 = Release|Any CPU + {24D7FCC6-63D4-BACC-CADC-75A14B351A9F}.Release|x64.ActiveCfg = Release|Any CPU + {24D7FCC6-63D4-BACC-CADC-75A14B351A9F}.Release|x64.Build.0 = Release|Any CPU + {24D7FCC6-63D4-BACC-CADC-75A14B351A9F}.Release|x86.ActiveCfg = Release|Any CPU + {24D7FCC6-63D4-BACC-CADC-75A14B351A9F}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE From 960498e0280be3e472926b18c3af45e1b051677e Mon Sep 17 00:00:00 2001 From: StarInk Date: Fri, 20 Feb 2026 19:57:19 +0100 Subject: [PATCH 011/110] =?UTF-8?q?=F0=9F=92=BE=20=F0=9F=A7=A9=20Feat,=20R?= =?UTF-8?q?efactor:=20Create=20KitX.Core=20and=20KitX.Core.Contract=20to?= =?UTF-8?q?=20separate=20the=20front-back-ends=20of=20the=20Dashboard.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 19 + .../KitX.Core.DI.Tests.csproj | 20 + .../KitX Core/KitX.Core.DI.Tests/Program.cs | 132 ++++ KitX Clients/KitX Core/KitX.Core/.gitignore | 77 +++ .../KitX.Core/Activity/ActivityManager.cs | 298 ++++++++++ .../Announcement/AnnouncementManager.cs | 331 +++++++++++ .../KitX.Core/Configuration/AppConfig.cs | 345 +++++++++++ .../KitX.Core/Configuration/ConfigManager.cs | 240 ++++++++ .../KitX Core/KitX.Core/ConstantTable.cs | 65 ++ .../DI/CoreServiceCollectionExtensions.cs | 163 +++++ .../KitX Core/KitX.Core/Device/DeviceCase.cs | 76 +++ .../Device/DevicesDiscoveryServer.cs | 416 +++++++++++++ .../KitX.Core/Device/DevicesOrganizer.cs | 344 +++++++++++ .../KitX.Core/Device/DevicesServer.cs | 250 ++++++++ .../KitX.Core/Device/NetworkHelper.cs | 182 ++++++ .../KitX.Core/Device/OperatingSystemHelper.cs | 41 ++ .../KitX.Core/Device/PluginsServer.cs | 459 ++++++++++++++ .../KitX.Core/Device/ServerStatus.cs | 13 + .../KitX Core/KitX.Core/Event/EventArgs.cs | 36 ++ .../KitX Core/KitX.Core/Event/EventNames.cs | 92 +++ .../KitX Core/KitX.Core/Event/EventService.cs | 255 ++++++++ .../FileWatcher/FileWatcherManager.cs | 207 +++++++ .../KitX.Core/Hotkey/KeyHookManager.cs | 143 +++++ .../KitX Core/KitX.Core/KitX.Core.csproj | 56 ++ .../KitX.Core/Plugin/PluginConnector.cs | 308 ++++++++++ .../KitX.Core/Plugin/PluginsManager.cs | 369 ++++++++++++ KitX Clients/KitX Core/KitX.Core/README.md | 97 +++ .../KitX.Core/Security/SecurityManager.cs | 352 +++++++++++ .../KitX.Core/Statistics/StatisticsManager.cs | 220 +++++++ .../KitX Core/KitX.Core/Task/TasksManager.cs | 127 ++++ .../Workflow/WorkflowScriptService.cs | 562 ++++++++++++++++++ KitX Clients/KitX Dashboard | 2 +- KitX Standard | 2 +- KitX.sln | 79 ++- 34 files changed, 6374 insertions(+), 4 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 KitX Clients/KitX Core/KitX.Core.DI.Tests/KitX.Core.DI.Tests.csproj create mode 100644 KitX Clients/KitX Core/KitX.Core.DI.Tests/Program.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/.gitignore create mode 100644 KitX Clients/KitX Core/KitX.Core/Activity/ActivityManager.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Announcement/AnnouncementManager.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Configuration/AppConfig.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Configuration/ConfigManager.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/ConstantTable.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Device/DeviceCase.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Device/DevicesDiscoveryServer.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Device/DevicesOrganizer.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Device/DevicesServer.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Device/NetworkHelper.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Device/OperatingSystemHelper.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Device/PluginsServer.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Device/ServerStatus.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Event/EventArgs.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Event/EventNames.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Event/EventService.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/FileWatcher/FileWatcherManager.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Hotkey/KeyHookManager.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/KitX.Core.csproj create mode 100644 KitX Clients/KitX Core/KitX.Core/Plugin/PluginConnector.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Plugin/PluginsManager.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/README.md create mode 100644 KitX Clients/KitX Core/KitX.Core/Security/SecurityManager.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Statistics/StatisticsManager.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Task/TasksManager.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..b772a9c2 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,19 @@ +{ + "permissions": { + "allow": [ + "Edit", + "Bash(cd:*)", + "Write", + "Bash(dir:*)", + "Bash(git:*)", + "Bash(find:*)", + "Bash(del:*)", + "Bash(dotnet build:*)" + ] + }, + "enabledPlugins": { + "csharp-lsp@claude-plugins-official": true, + "code-review@claude-plugins-official": true, + "ralph-loop@claude-plugins-official": true + } +} diff --git a/KitX Clients/KitX Core/KitX.Core.DI.Tests/KitX.Core.DI.Tests.csproj b/KitX Clients/KitX Core/KitX.Core.DI.Tests/KitX.Core.DI.Tests.csproj new file mode 100644 index 00000000..e7b22a74 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core.DI.Tests/KitX.Core.DI.Tests.csproj @@ -0,0 +1,20 @@ + + + + Exe + net10.0 + enable + enable + KitX.Core.DI.Tests + + + + + + + + + + + + diff --git a/KitX Clients/KitX Core/KitX.Core.DI.Tests/Program.cs b/KitX Clients/KitX Core/KitX.Core.DI.Tests/Program.cs new file mode 100644 index 00000000..f242375a --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core.DI.Tests/Program.cs @@ -0,0 +1,132 @@ +using Microsoft.Extensions.DependencyInjection; +using KitX.Core.DI; +using KitX.Core.Contract.Configuration; + +namespace KitX.Core.DI.Tests; + +/// +/// Complete test suite for DI container verification +/// +public class Program +{ + public static void Main(string[] args) + { + Console.WriteLine("╔════════════════════════════════════════════════════════╗"); + Console.WriteLine("║ KitX Core DI Container - Complete Test Suite ║"); + Console.WriteLine("╚════════════════════════════════════════════════════════╝\n"); + + try + { + TestServiceResolution(); + TestSingletonLifecycle(); + + Console.WriteLine("\n" + new string('═', 54)); + Console.WriteLine("║ 🎉 All Tests Passed Successfully! ║"); + Console.WriteLine(new string('═', 54)); + Console.WriteLine("\n📋 Final Verification Results:"); + Console.WriteLine(" ✅ DI container initialization"); + Console.WriteLine(" ✅ All 10 services registered correctly"); + Console.WriteLine(" ✅ All services can be resolved"); + Console.WriteLine(" ✅ Singleton lifecycle working correctly"); + Console.WriteLine(" ✅ Backward compatibility with static Instance"); + Console.WriteLine("\n🚀 Phase 3 is complete and fully verified!"); + Console.WriteLine("═════════════════════════════════════════════════════════\n"); + } + catch (Exception ex) + { + Console.WriteLine($"\n❌ Test suite failed: {ex.Message}"); + Console.WriteLine($"Stack trace: {ex.StackTrace}"); + Environment.Exit(1); + } + } + + private static void TestServiceResolution() + { + Console.WriteLine("┌─────────────────────────────────────────────────────────┐"); + Console.WriteLine("│ Test 1: Service Resolution │"); + Console.WriteLine("└─────────────────────────────────────────────────────────┘\n"); + + var services = new ServiceCollection(); + services.AddCoreServices(); + var serviceProvider = services.BuildServiceProvider(); + + Console.WriteLine("✅ DI Container built successfully\n"); + Console.WriteLine("Testing service resolution:\n"); + + TestService(serviceProvider, "IConfigService"); + TestService(serviceProvider, "ISecurityService"); + TestService(serviceProvider, "IPluginService"); + TestService(serviceProvider, "IWorkflowService"); + TestService(serviceProvider, "IActivityService"); + TestService(serviceProvider, "IStatisticsService"); + TestService(serviceProvider, "ITasksService"); + TestService(serviceProvider, "IFileWatcherService"); + TestService(serviceProvider, "IKeyHookService"); + TestService(serviceProvider, "IEventService"); + + Console.WriteLine("\n✅ Test 1 Passed: All services resolved successfully\n"); + } + + private static void TestSingletonLifecycle() + { + Console.WriteLine("┌─────────────────────────────────────────────────────────┐"); + Console.WriteLine("│ Test 2: Singleton Lifecycle │"); + Console.WriteLine("└─────────────────────────────────────────────────────────┘\n"); + + var services = new ServiceCollection(); + services.AddCoreServices(); + var serviceProvider = services.BuildServiceProvider(); + + // Resolve service twice + var service1 = serviceProvider.GetRequiredService(); + var service2 = serviceProvider.GetRequiredService(); + + // Check if they are the same instance + bool isSameInstance = ReferenceEquals(service1, service2); + + Console.WriteLine($"Resolution Test:"); + Console.WriteLine($" • First call hash code: {service1.GetHashCode()}"); + Console.WriteLine($" • Second call hash code: {service2.GetHashCode()}"); + Console.WriteLine($" • Same instance? {(isSameInstance ? "✅ Yes" : "❌ No")}"); + + if (!isSameInstance) + { + throw new InvalidOperationException("Singleton lifecycle not working correctly"); + } + + // Verify it's the same as ConfigManager.Instance + bool isSameAsStatic = ReferenceEquals(service1, KitX.Core.Configuration.ConfigManager.Instance); + Console.WriteLine($"\nBackward Compatibility Test:"); + Console.WriteLine($" • Same as static Instance? {(isSameAsStatic ? "✅ Yes" : "❌ No")}"); + + if (!isSameAsStatic) + { + Console.WriteLine("\n⚠️ Warning: DI instance differs from static Instance"); + Console.WriteLine(" This may indicate a configuration issue."); + } + + Console.WriteLine("\n✅ Test 2 Passed: Singleton lifecycle verified\n"); + } + + private static void TestService(IServiceProvider serviceProvider, string serviceName) where T : notnull + { + try + { + var service = serviceProvider.GetRequiredService(); + if (service == null) + { + throw new InvalidOperationException($"{serviceName} resolved to null"); + } + + var actualType = service.GetType().FullName; + var shortName = actualType?.Split('.').Last(); + + Console.WriteLine($" ✅ {serviceName,-25} → {shortName}"); + } + catch (Exception ex) + { + Console.WriteLine($" ❌ {serviceName}: {ex.Message}"); + throw; + } + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/.gitignore b/KitX Clients/KitX Core/KitX.Core/.gitignore new file mode 100644 index 00000000..15478bdd --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/.gitignore @@ -0,0 +1,77 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NuGet Packages +*.nupkg +**/packages/* +!**/packages/build/ +*.nuget.props +*.nuget.targets + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# ReSharper +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JetBrains Rider +.idea/ +*.sln.iml + +# Windows image file caches +Thumbs.db +ehthumbs.db + +# Folder config file +Desktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Mac crap +.DS_Store + +# Windows Store app package +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt + +# Visual Studio cache files +project.lock.json +project.fragment.lock.json +artifacts/ diff --git a/KitX Clients/KitX Core/KitX.Core/Activity/ActivityManager.cs b/KitX Clients/KitX Core/KitX.Core/Activity/ActivityManager.cs new file mode 100644 index 00000000..a1334ce9 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Activity/ActivityManager.cs @@ -0,0 +1,298 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using Common.Activity; +using CActivity = Common.Activity.Activity; +using Common.BasicHelper.Utils.Extensions; +using KitX.Core.Contract.Activity; +using KitX.Core.Event; +using LiteDB; +using KitX.Core.Task; + +namespace KitX.Core.Activity; + +/// +/// Activity manager for recording application activities +/// Uses Common.Activity library for activity management and LiteDB for persistence +/// +public class ActivityManager : IActivityService +{ + private static ActivityManager? _instance; + private static readonly object _activityRecordLock = new(); + + /// + /// Gets the singleton instance + /// + internal static ActivityManager Instance => _instance ??= new(); + + private static LiteDatabase? _activitiesDatabase; + + /// + /// Gets or sets the activities database + /// + public static LiteDatabase? ActivitiesDatabase + { + get => _activitiesDatabase; + set => _activitiesDatabase = value; + } + + /// + /// Gets the collection name for current month + /// + public static string CollectionName => DateTime.UtcNow.ToString("yyyy_MM").Num2UpperChar(); + + private CActivity? _appActivity; + + /// + /// Event raised when activities are updated + /// + public event EventHandler? ActivitiesUpdated; + + /// + /// Private constructor + /// + private ActivityManager() { } + + /// + /// Reads activities from the database (static method for backward compatibility) + /// + /// List of activities + public static IList ReadActivities() + { + if (_activitiesDatabase is LiteDatabase db) + { + var col = db.GetCollection(CollectionName); + return col.FindAll().ToList(); + } + else + return []; + } + + /// + /// Records an activity to the database + /// + /// The activity to record + /// Key selector for indexing + public void Record(CActivity activity, Expression> keySelector) + { + const string location = $"{nameof(ActivityManager)}.{nameof(Record)}"; + + TasksManager.RunTask( + () => + { + lock (_activityRecordLock) + { + if (_activitiesDatabase is LiteDatabase db) + { + var col = db.GetCollection(CollectionName); + + col?.Insert(activity); + + col?.EnsureIndex(keySelector); + + db.Commit(); + + ActivitiesUpdated?.Invoke(this, EventArgs.Empty); + } + } + }, + location, + catchException: true + ); + } + + /// + /// Updates an activity in the database + /// + /// The activity to update + public void Update(CActivity activity) + { + const string location = $"{nameof(ActivityManager)}.{nameof(Update)}"; + + TasksManager.RunTask( + () => + { + lock (_activityRecordLock) + { + if (_activitiesDatabase is LiteDatabase db) + { + var col = db.GetCollection(CollectionName); + + col?.Update(activity); + + db.Commit(); + + ActivitiesUpdated?.Invoke(this, EventArgs.Empty); + } + } + }, + location, + catchException: true + ); + } + + /// + /// Records an activity (interface implementation for backward compatibility) + /// + /// Activity type + /// Activity details + public void RecordActivity(string type, Dictionary? details = null) + { + // This method is kept for interface compatibility but delegates to Record() + // Actual implementation should use Record() with Activity objects + var activity = new CActivity() + { + Id = DateTime.UtcNow.Ticks.GetHashCode(), // Simple ID generation + Name = type, + Author = "KitX", + Title = type, + Category = "General" + }; + + Record(activity, x => x.Id); + } + + /// + /// Gets activities (interface implementation) + /// + /// Optional start date filter + /// Optional end date filter + /// Maximum number of activities to return + /// List of activities + public IList GetActivities(DateTime? startDate = null, DateTime? endDate = null, int limit = 100) + { + var activities = ReadActivities(); + + // Filter by date range if specified + if (startDate.HasValue || endDate.HasValue) + { + activities = activities.Where(a => + { + // Activity doesn't have Timestamp, so we skip date filtering for now + // TODO: Add Timestamp property to Common.Activity.Activity or use alternative filtering + return true; + }).ToList(); + } + + // Apply limit + if (limit > 0 && activities.Count > limit) + { + activities = activities.Take(limit).ToList(); + } + + // Convert to IActivity interface + return activities.Select(a => new ActivityAdapter(a)).ToList(); + } + + /// + /// Gets activity statistics + /// + /// Start date + /// End date + /// Activity statistics + public IActivityStatistics GetStatistics(DateTime startDate, DateTime endDate) + { + var activities = GetActivities(startDate, endDate); + + var statistics = new ActivityStatistics + { + TotalActivities = activities.Count + }; + + foreach (var activity in activities) + { + if (!statistics.ActivitiesByType.ContainsKey(activity.Type)) + { + statistics.ActivitiesByType[activity.Type] = 0; + } + + statistics.ActivitiesByType[activity.Type]++; + } + + return statistics; + } + + /// + /// Updates an activity (interface implementation) + /// + /// The activity to update + public void UpdateActivity(IActivity activity) + { + if (activity is ActivityAdapter adapter) + { + Update(adapter.Activity); + } + } + + /// + /// Records app start + /// + public void RecordAppStart() + { + var activity = new CActivity() + { + Id = DateTime.UtcNow.Ticks.GetHashCode(), + Name = "AppLifetime", + Author = "KitX Dashboard", + Title = "Application Started", + Category = "DashboardEvent", + IconKind = Material.Icons.MaterialIconKind.RocketLaunch, + }.Open("KitX Dashboard"); + + _appActivity = activity; + + Record(activity, x => x.Id); + } + + /// + /// Records app exit + /// + public void RecordAppExit() + { + if (_appActivity is CActivity activity) + { + activity.Close("KitX Dashboard"); + + Update(activity); + } + } + + /// + /// Activity adapter to convert Common.Activity.Activity to IActivity + /// + private class ActivityAdapter : IActivity + { + private readonly CActivity _activity; + + public ActivityAdapter(CActivity activity) + { + _activity = activity; + } + + public CActivity Activity => _activity; + + public string Id => _activity.Id.ToString(); + + public string Type => _activity.Name ?? "Unknown"; + + public DateTime Timestamp => DateTime.UtcNow; // Activity doesn't have Timestamp, use current time + + public Dictionary Details => new() + { + { "Title", _activity.Title ?? "" }, + { "Category", _activity.Category ?? "" }, + { "Author", _activity.Author ?? "" }, + { "Status", _activity.Status.ToString() } + }; + } + + /// + /// Activity statistics implementation + /// + private class ActivityStatistics : IActivityStatistics + { + public int TotalActivities { get; set; } + public Dictionary ActivitiesByType { get; set; } = new(); + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Announcement/AnnouncementManager.cs b/KitX Clients/KitX Core/KitX.Core/Announcement/AnnouncementManager.cs new file mode 100644 index 00000000..99b31e90 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Announcement/AnnouncementManager.cs @@ -0,0 +1,331 @@ +using KitX.Core.Configuration; +using KitX.Core.Contract.Announcement; +using KitX.Core.Contract.Configuration; +using Microsoft.AspNetCore.Components; +using Serilog; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; + +namespace KitX.Core.Announcement; + +/// +/// Announcement manager for checking and displaying announcements +/// Phase 5: Decoupled from UI, uses events instead +/// +public class AnnouncementManager : IAnnouncementService +{ + private static AnnouncementManager? _instance; + + /// + /// Gets the singleton instance + /// + internal static AnnouncementManager Instance => _instance ??= new(); + + private readonly HashSet _acceptedAnnouncementIds = new(); + private readonly JsonSerializerOptions _serializerOptions = new() + { + PropertyNameCaseInsensitive = true, + IncludeFields = true, + }; + + private readonly IConfigService? _configService; + + /// + /// Gets the announcement configuration + /// + public IAnnouncementConfig AnnouncementConfig => ConfigManager.Instance.TypedAnnouncementConfig; + + /// + /// Event raised when new announcements are available + /// Instead of directly creating UI windows, Core triggers events + /// + public event EventHandler? NewAnnouncementsAvailable; + + /// + /// Event raised when announcement checking fails + /// + public event EventHandler? AnnouncementError; + + /// + /// Private constructor + /// + private AnnouncementManager() + { + LoadAcceptedIds(); + } + + /// + /// Constructor with IConfigService injection + /// + /// Configuration service + public AnnouncementManager(IConfigService configService) : this() + { + _configService = configService; + } + + /// + /// Checks for new announcements + /// + /// List of new announcements + public async Task> CheckNewAnnouncementsAsync() + { + const string location = $"{nameof(AnnouncementManager)}.{nameof(CheckNewAnnouncementsAsync)}"; + + try + { + // Get API server and path from config service + string apiServer; + string apiPath; + + if (_configService != null) + { + apiServer = _configService.AppConfig.Web?.ApiServer ?? "api.example.com"; + apiPath = _configService.AppConfig.Web?.ApiPath ?? "/api/v1"; + } + else + { + // Fallback to legacy config if DI not available + apiServer = ConfigManager.Instance.AppConfig.Web.ApiServer; + apiPath = ConfigManager.Instance.AppConfig.Web.ApiPath; + } + + var linkBase = $"https://{apiServer}{apiPath}"; + + var announcementsLink = $"{linkBase}/announcements"; + var unreads = new List(); + + using var client = new HttpClient(); + client.DefaultRequestHeaders.Accept.Clear(); + + // Fetch announcement dates + var msg = await client.GetStringAsync(announcementsLink); + var list = JsonSerializer.Deserialize>(msg); + + if (list is null) + return Array.Empty(); + + // Filter unread announcements + foreach (var item in list) + { + if (!_acceptedAnnouncementIds.Contains(item)) + { + if (DateTime.TryParse(item, out var date)) + { + unreads.Add(date); + } + } + } + + // Fetch announcement details + var announcements = new List(); + foreach (var item in unreads) + { + var announcementLink = $"{linkBase}/announcement?lang=en&date={item:yyyy-MM-dd HH-mm}"; + var markdown = JsonSerializer.Deserialize(await client.GetStringAsync(announcementLink)); + + if (!string.IsNullOrEmpty(markdown)) + { + announcements.Add(new Announcement + { + Id = item.ToString("yyyy-MM-dd HH:mm"), + PublishDate = item, + Content = markdown, + Title = $"Announcement - {item:yyyy-MM-dd}", + Version = "1.0" // TODO: Get from API if available + }); + } + } + + // If new announcements found, trigger event + if (announcements.Count > 0) + { + NewAnnouncementsAvailable?.Invoke(this, new NewAnnouncementsEventArgs + { + Announcements = announcements + }); + } + + return announcements; + } + catch (Exception ex) + { + Log.Error(ex, $"In {location}: {ex.Message}"); + return Array.Empty(); + } + } + + /// + /// Marks an announcement as read + /// + /// The announcement ID + public void MarkAsRead(string announcementId) + { + _acceptedAnnouncementIds.Add(announcementId); + SaveAcceptedIds(); + } + + /// + /// Gets all read announcement IDs + /// + /// List of read announcement IDs + public IReadOnlyList GetReadAnnouncementIds() + { + return _acceptedAnnouncementIds.ToList(); + } + + /// + /// Saves the announcement configuration + /// + public void SaveAnnouncementConfig() + { + var config = AnnouncementConfig; + if (!string.IsNullOrEmpty(config.ConfigFileLocation) && config is Configuration.AnnouncementConfig typedConfig) + { + typedConfig.Save(config.ConfigFileLocation); + } + } + + /// + /// Loads accepted announcement IDs from storage + /// TODO: Implement persistence (file or database) + /// + private void LoadAcceptedIds() + { + // TODO: Load from config file + // For now, initialize as empty + } + + /// + /// Saves accepted announcement IDs to storage + /// TODO: Implement persistence (file or database) + /// + private void SaveAcceptedIds() + { + // TODO: Save to config file + } + + public static async System.Threading.Tasks.Task CheckNewAnnouncements() + { + const string location = $"{nameof(AnnouncementManager)}.{nameof(CheckNewAnnouncements)}"; + + // Use the singleton instance which now has IConfigService injected + var instance = Instance; + + string apiServer; + string apiPath; + string appLanguage; + + if (instance._configService != null) + { + apiServer = instance._configService.AppConfig.Web?.ApiServer ?? ConfigManager.Instance.AppConfig.Web.ApiServer; + apiPath = instance._configService.AppConfig.Web?.ApiPath ?? ConfigManager.Instance.AppConfig.Web.ApiPath; + appLanguage = instance._configService.AppConfig.App?.AppLanguage ?? ConfigManager.Instance.AppConfig.App.AppLanguage; + } + else + { + // Fallback to legacy config + apiServer = ConfigManager.Instance.AppConfig.Web.ApiServer; + apiPath = ConfigManager.Instance.AppConfig.Web.ApiPath; + appLanguage = ConfigManager.Instance.AppConfig.App.AppLanguage; + } + + var linkBase = new StringBuilder().Append("https://").Append(apiServer).Append(apiPath).ToString(); + + var link = new StringBuilder().Append(linkBase).Append(ConstantTable.ApiGetAnnouncements).ToString(); + + try + { + using var client = new HttpClient(); + + client.DefaultRequestHeaders.Accept.Clear(); + + var msg = await client.GetStringAsync(link); + + var list = JsonSerializer.Deserialize>(msg); + + // Get accepted announcement IDs from config + var accepted = ConfigManager.Instance.AnnouncementConfig.Accepted; + + if (list is null) + return; + + var unreads = (from item in list where !accepted.Contains(item) select DateTime.Parse(item)).ToList(); + + var src = new Dictionary(); + + foreach (var item in unreads) + { + var apiLink = new StringBuilder() + .Append($"{linkBase}{ConstantTable.ApiGetAnnouncement}") + .Append('?') + .Append($"lang={appLanguage}") + .Append('&') + .Append($"date={item:yyyy-MM-dd HH-mm}") + .ToString(); + + var md = JsonSerializer.Deserialize(await client.GetStringAsync(apiLink)); + + if (md is not null) + src.Add(item.ToString("yyyy-MM-dd HH:mm"), md); + } + + if (unreads.Count > 0) + { + // Trigger event instead of directly showing UI + // UI layer should subscribe to this event + Instance.NewAnnouncementsAvailable?.Invoke(Instance, new NewAnnouncementsEventArgs + { + AnnouncementsDict = src + }); + } + } + catch (Exception ex) + { + Log.Error(ex, $"In {location}: {ex.Message}"); + + // Trigger error event instead of showing MessageBox + // UI layer should subscribe to this event + Instance.AnnouncementError?.Invoke(Instance, new AnnouncementErrorEventArgs + { + ErrorMessage = ex.Message, + StackTrace = ex.StackTrace ?? string.Empty + }); + } + } + + /// + /// Announcement implementation + /// + public class Announcement : IAnnouncement + { + /// + /// Gets or sets the announcement ID + /// + public string Id { get; set; } = string.Empty; + + /// + /// Gets or sets the announcement title + /// + public string Title { get; set; } = string.Empty; + + /// + /// Gets or sets the announcement content + /// + public string Content { get; set; } = string.Empty; + + /// + /// Gets or sets the publish date + /// + public DateTime PublishDate { get; set; } + + /// + /// Gets or sets the version + /// + public string Version { get; set; } = string.Empty; + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Configuration/AppConfig.cs b/KitX Clients/KitX Core/KitX.Core/Configuration/AppConfig.cs new file mode 100644 index 00000000..58d359c9 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Configuration/AppConfig.cs @@ -0,0 +1,345 @@ +using System; +using System.Collections.Generic; +using Common.BasicHelper.Graphics.Screen; +using KitX.Core.Contract.Configuration; +using Serilog.Events; + +namespace KitX.Core.Configuration; + +/// +/// Application configuration implementation +/// This class contains all application settings organized into logical sections +/// +public class AppConfig : IAppConfig +{ + public Config_App App { get; set; } = new(); + + public Config_Windows Windows { get; set; } = new(); + + public Config_Pages Pages { get; set; } = new(); + + public Config_Web Web { get; set; } = new(); + + public Config_Log Log { get; set; } = new(); + + public Config_IO IO { get; set; } = new(); + + public Config_Activity Activity { get; set; } = new(); + + public Config_Loaders Loaders { get; set; } = new(); + + // Explicit interface implementation with setters + IAppConf IAppConfig.App { get => App; set => App = (Config_App?)value ?? new(); } + IWindowsConf IAppConfig.Windows { get => Windows; set => Windows = (Config_Windows?)value ?? new(); } + IPagesConf IAppConfig.Pages { get => Pages; set => Pages = (Config_Pages?)value ?? new(); } + IWebConf IAppConfig.Web { get => Web; set => Web = (Config_Web?)value ?? new(); } + ILogConf IAppConfig.Log { get => Log; set => Log = (Config_Log?)value ?? new(); } + IIOConf IAppConfig.IO { get => IO; set => IO = (Config_IO?)value ?? new(); } + IActivityConf IAppConfig.Activity { get => Activity; set => Activity = (Config_Activity?)value ?? new(); } + ILoadersConf IAppConfig.Loaders { get => Loaders; set => Loaders = (Config_Loaders?)value ?? new(); } + + /// + /// Application configuration section + /// + public class Config_App : IAppConf + { + public string IconFileName { get; set; } = "KitX-Icon-1920x-margin-2x.png"; + + public string CoverIconFileName { get; set; } = "KitX-Icon-Background.png"; + + public string AppLanguage { get; set; } = "zh-cn"; + + public string Theme { get; set; } = "Follow"; + + public string ThemeColor { get; set; } = "#FF3873D9"; + + public Dictionary SurpportLanguages { get; set; } = + new() + { + { "zh-cn", "中文 (简体)" }, + { "zh-tw", "中文 (繁體)" }, + { "ru-ru", "Русский" }, + { "en-us", "English (US)" }, + { "fr-fr", "Français" }, + { "ja-jp", "日本語" }, + { "ko-kr", "한국어" }, + }; + + public string LocalPluginsFileFolder { get; set; } = "./Plugins/"; + + public string LocalPluginsDataFolder { get; set; } = "./PluginsDatas/"; + + public bool DeveloperSetting { get; set; } = false; + + public bool ShowAnnouncementWhenStart { get; set; } = true; + + public ulong RanTime { get; set; } = 0; + + public int LastBreakAfterExit { get; set; } = 2000; + } + + /// + /// Windows configuration section + /// + public class Config_Windows : IWindowsConf + { + public Config_MainWindow MainWindow { get; set; } = new(); + + public Config_AnnouncementWindow AnnouncementWindow { get; set; } = new(); + + // Explicit interface implementation with setters + IMainWindowConf IWindowsConf.MainWindow { get => MainWindow; set => MainWindow = (Config_MainWindow?)value ?? new(); } + IAnnouncementWindowConf IWindowsConf.AnnouncementWindow { get => AnnouncementWindow; set => AnnouncementWindow = (Config_AnnouncementWindow?)value ?? new(); } + + /// + /// Main window configuration + /// + public class Config_MainWindow : IMainWindowConf + { + private Resolution _size = Resolution.Parse("1280x720"); + private Distances _location = new(left: -1, top: -1); + + /// + /// Window size (strong type) + /// + public Resolution Size + { + get => _size; + set => _size = value; + } + + /// + /// Window location (strong type) + /// + public Distances Location + { + get => _location; + set => _location = value; + } + + /// + /// Window state (strong type) + /// + public WindowState WindowState { get; set; } = WindowState.Normal; + + public bool IsHidden { get; set; } = false; + + public Dictionary Tags { get; set; } = new() { { "SelectedPage", "Page_Home" } }; + + public bool EnabledMica { get; set; } = true; + + public int GreetingTextCount_Morning { get; set; } = 5; + + public int GreetingTextCount_Noon { get; set; } = 3; + + public int GreetingTextCount_AfterNoon { get; set; } = 3; + + public int GreetingTextCount_Evening { get; set; } = 2; + + public int GreetingTextCount_Night { get; set; } = 4; + + public int GreetingUpdateInterval { get; set; } = 10; + } + + /// + /// Announcement window configuration + /// + public class Config_AnnouncementWindow : IAnnouncementWindowConf + { + private Resolution _size = Resolution.Parse("1280x720"); + private Distances _location = new(left: -1, top: -1); + + /// + /// Window size (strong type) + /// + public Resolution Size + { + get => _size; + set => _size = value; + } + + /// + /// Window location (strong type) + /// + public Distances Location + { + get => _location; + set => _location = value; + } + } + } + + /// + /// Pages configuration section + /// + public class Config_Pages : IPagesConf + { + public Config_HomePage Home { get; set; } = new(); + + public Config_DevicePage Device { get; set; } = new(); + + public Config_MarketPage Market { get; set; } = new(); + + public Config_SettingsPage Settings { get; set; } = new(); + + // Explicit interface implementation with setters + IHomePageConf IPagesConf.Home { get => Home; set => Home = (Config_HomePage?)value ?? new(); } + IDevicePageConf IPagesConf.Device { get => Device; set => Device = (Config_DevicePage?)value ?? new(); } + IMarketPageConf IPagesConf.Market { get => Market; set => Market = (Config_MarketPage?)value ?? new(); } + ISettingsPageConf IPagesConf.Settings { get => Settings; set => Settings = (Config_SettingsPage?)value ?? new(); } + + /// + /// Home page configuration + /// + public class Config_HomePage : IHomePageConf + { + public NavigationViewPaneDisplayMode NavigationViewPaneDisplayMode { get; set; } = NavigationViewPaneDisplayMode.Auto; + + public string SelectedViewName { get; set; } = "View_Recent"; + + public bool IsNavigationViewPaneOpened { get; set; } = true; + + public bool UseAreaExpanded { get; set; } = true; + } + + /// + /// Device page configuration + /// + public class Config_DevicePage : IDevicePageConf { } + + /// + /// Market page configuration + /// + public class Config_MarketPage : IMarketPageConf { } + + /// + /// Settings page configuration + /// + public class Config_SettingsPage : ISettingsPageConf + { + public NavigationViewPaneDisplayMode NavigationViewPaneDisplayMode { get; set; } = NavigationViewPaneDisplayMode.Auto; + + public string SelectedViewName { get; set; } = "View_General"; + + public bool PaletteAreaExpanded { get; set; } = false; + + public bool WebRelatedAreaExpanded { get; set; } = true; + + public bool WebRelatedAreaOfNetworkInterfacesExpanded { get; set; } = false; + + public bool LogRelatedAreaExpanded { get; set; } = true; + + public bool UpdateRelatedAreaExpanded { get; set; } = true; + + public bool AboutAreaExpanded { get; set; } = false; + + public bool AuthorsAreaExpanded { get; set; } = false; + + public bool LinksAreaExpanded { get; set; } = false; + + public bool ThirdPartyLicensesAreaExpanded { get; set; } = false; + + public bool IsNavigationViewPaneOpened { get; set; } = true; + } + } + + /// + /// Web configuration section + /// + public class Config_Web : IWebConf + { + public double DelayStartSeconds { get; set; } = 0.5; + + public string ApiServer { get; set; } = "api.catrol.cn"; + + public string ApiPath { get; set; } = "/apps/kitx/"; + + public int DevicesViewRefreshDelay { get; set; } = 1000; + + public List? AcceptedNetworkInterfaces { get; set; } = null; + + public int? UserSpecifiedDevicesServerPort { get; set; } = null; + + public int? UserSpecifiedPluginsServerPort { get; set; } = null; + + public int UdpPortSend { get; set; } = 23404; + + public int UdpPortReceive { get; set; } = 24040; + + public int UdpSendFrequency { get; set; } = 1000; + + public string UdpBroadcastAddress { get; set; } = "224.0.0.0"; + + public string IPFilter { get; set; } = "192.168"; + + public int SocketBufferSize { get; set; } = 1024 * 100; + + public int DeviceInfoTTLSeconds { get; set; } = 7; + + public bool DisableRemovingOfflineDeviceCard { get; set; } = false; + + public string UpdateServer { get; set; } = "api.catrol.cn"; + + public string UpdatePath { get; set; } = "/apps/kitx/%platform%/"; + + public string UpdateDownloadPath { get; set; } = "/apps/kitx/update/%platform%/"; + + public string UpdateChannel { get; set; } = "stable"; + + public string UpdateSource { get; set; } = "latest-components.json"; + + public int DebugServicesServerPort { get; set; } = 7777; + } + + /// + /// Log configuration section + /// + public class Config_Log : ILogConf + { + public long LogFileSingleMaxSize { get; set; } = 1024 * 1024 * 10; // 10MB + + public string LogFilePath { get; set; } = "./Log/"; + + public string LogTemplate { get; set; } = "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] {Message:lj}{NewLine}{Exception}"; + + public int LogFileMaxCount { get; set; } = 50; + + public int LogFileFlushInterval { get; set; } = 30; + +#if DEBUG + + public LogEventLevel LogLevel { get; set; } = LogEventLevel.Information; + +#else + + public LogEventLevel LogLevel { get; set; } = LogEventLevel.Warning; + +#endif + } + + /// + /// IO configuration section + /// + public class Config_IO : IIOConf + { + public int UpdatingCheckPerThreadFilesCount { get; set; } = 20; + + public int OperatingSystemVersionUpdateInterval { get; set; } = 60; + } + + /// + /// Activity configuration section + /// + public class Config_Activity : IActivityConf + { + public int TotalRecorded { get; set; } = 0; + } + + /// + /// Loaders configuration section + /// + public class Config_Loaders : ILoadersConf + { + public string InstallPath { get; set; } = "./Loaders/"; + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigManager.cs b/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigManager.cs new file mode 100644 index 00000000..9b6cb114 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigManager.cs @@ -0,0 +1,240 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using KitX.Core.Contract.Announcement; +using KitX.Core.Contract.Configuration; +using KitX.Shared.CSharp.Device; +using KitX.Shared.CSharp.Loader; +using KitX.Shared.CSharp.Plugin; +using Serilog; + +namespace KitX.Core.Configuration; + +/// +/// Configuration manager for managing application configurations +/// +public class ConfigManager : IConfigService +{ + private static ConfigManager? _instance; + + /// + /// Gets the singleton instance + /// + internal static ConfigManager Instance => _instance ??= new(); + + private string? _configLocation; + + private readonly Dictionary _configs = new(); + + /// + /// Event raised when configuration changes + /// + public event EventHandler? ConfigChanged; + + /// + /// Gets the application configuration + /// + public IAppConfig AppConfig { get; private set; } = new AppConfig(); + + /// + /// Gets the typed application configuration (strong type version) + /// + public AppConfig TypedAppConfig => (AppConfig)AppConfig; + + public IAnnouncementConfig AnnouncementConfig { get; set; } = new AnnouncementConfig(); + + /// + /// Gets the typed announcement configuration (strong type version) + /// + public AnnouncementConfig TypedAnnouncementConfig => (AnnouncementConfig)AnnouncementConfig; + + /// + /// Gets the plugins configuration + /// + public IPluginsConfig PluginsConfig { get; private set; } = new PluginsConfig(); + + /// + /// Gets the security configuration + /// + public ISecurityConfig SecurityConfig { get; private set; } = new SecurityConfig(); + + /// + /// Private constructor + /// + private ConfigManager() { } + + /// + /// Sets the configuration file location + /// + /// The directory path + /// The config manager instance + public ConfigManager SetLocation(string location) + { + _configLocation = Path.GetFullPath(location); + + if (!Directory.Exists(_configLocation)) + { + Directory.CreateDirectory(_configLocation); + } + + return this; + } + + /// + /// Loads all configurations from files + /// + public void Load() + { + if (string.IsNullOrEmpty(_configLocation)) + { + SetLocation("./Config/"); + } + + LoadConfigFile("AppConfig.json"); + LoadConfigFile("PluginsConfig.json"); + LoadConfigFile("SecurityConfig.json"); + } + + /// + /// Saves all configurations to files + /// + public void SaveAll() + { + SaveConfigFile(AppConfig, "AppConfig.json"); + SaveConfigFile(PluginsConfig, "PluginsConfig.json"); + SaveConfigFile(SecurityConfig, "SecurityConfig.json"); + } + + /// + /// Reloads all configurations from files + /// + public void Reload() + { + Load(); + } + + private void LoadConfigFile(string fileName) where T : class, new() + { + try + { + var path = Path.Combine(_configLocation!, fileName); + + if (File.Exists(path)) + { + var json = File.ReadAllText(path); + var config = JsonSerializer.Deserialize(json); + + if (config != null) + { + _configs[typeof(T).Name] = config; + + // Update the public properties + if (typeof(T) == typeof(AppConfig)) + AppConfig = config as IAppConfig ?? new AppConfig(); + else if (typeof(T) == typeof(PluginsConfig)) + PluginsConfig = config as IPluginsConfig ?? new PluginsConfig(); + else if (typeof(T) == typeof(SecurityConfig)) + SecurityConfig = config as ISecurityConfig ?? new SecurityConfig(); + } + } + else + { + // Create default config + var config = new T(); + _configs[typeof(T).Name] = config; + + // Save default config + SaveConfigFile(config, fileName); + + // Update the public properties + if (typeof(T) == typeof(AppConfig)) + AppConfig = config as IAppConfig ?? new AppConfig(); + else if (typeof(T) == typeof(PluginsConfig)) + PluginsConfig = config as IPluginsConfig ?? new PluginsConfig(); + else if (typeof(T) == typeof(SecurityConfig)) + SecurityConfig = config as ISecurityConfig ?? new SecurityConfig(); + } + } + catch (Exception ex) + { + Log.Error(ex, $"Error loading config file {fileName}: {ex.Message}"); + } + } + + private void SaveConfigFile(T config, string fileName) + { + try + { + var path = Path.Combine(_configLocation!, fileName); + + var options = new JsonSerializerOptions + { + WriteIndented = true + }; + + var json = JsonSerializer.Serialize(config, options); + File.WriteAllText(path, json); + } + catch (Exception ex) + { + Log.Error(ex, $"Error saving config file {fileName}: {ex.Message}"); + } + } + + /// + /// Raises the config changed event + /// + /// The configuration type + /// The property name that changed + /// The old value + /// The new value + protected void OnConfigChanged(string configType, string propertyName, object? oldValue = null, object? newValue = null) + { + ConfigChanged?.Invoke(this, new ConfigChangedEventArgs + { + ConfigType = configType, + PropertyName = propertyName, + OldValue = oldValue, + NewValue = newValue + }); + } +} + +public class AnnouncementConfig : IAnnouncementConfig +{ + public List Accepted { get; set; } = []; + + /// + /// Configuration file location (for backward compatibility) + /// + public string? ConfigFileLocation { get; set; } + + /// + /// Saves the configuration to file (for backward compatibility) + /// + /// File path to save + /// This instance + public AnnouncementConfig Save(string path) + { + // Implementation would save to file - simplified for compatibility + return this; + } +} + + +/// +/// Plugins configuration implementation +/// +public class PluginsConfig : IPluginsConfig +{ + public IList Plugins { get; set; } = []; +} + +/// +/// Security configuration implementation +/// +public class SecurityConfig : ISecurityConfig +{ + public IDictionary DeviceKeys { get; set; } = new Dictionary(); +} diff --git a/KitX Clients/KitX Core/KitX.Core/ConstantTable.cs b/KitX Clients/KitX Core/KitX.Core/ConstantTable.cs new file mode 100644 index 00000000..15d775d1 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/ConstantTable.cs @@ -0,0 +1,65 @@ +using System; +using Common.BasicHelper.Utils.Extensions; + +namespace KitX.Core; + +internal static class ConstantTable +{ + internal const string AppName = "KitX"; + + internal const string AppFullName = "KitX Dashboard"; + + internal const string DataPath = "./Data/"; + + internal const string LanguageFilePath = "./Languages/"; + + internal const string AssetsPath = "./Assets/"; + + internal const string UpdateSavePath = "./Update/"; + + internal const string IconBase64FileName = "KitX.Base64.txt"; + + private const string activitiesDataBaseFilePath = $"{DataPath}Activities.db"; + + private const string thirdPartyLicenseFilePath = $"{AssetsPath}ThirdPartyLicense.md"; + + internal static string ActivitiesDataBaseFilePath => activitiesDataBaseFilePath.GetFullPath(); + + internal static string ThirdPartyLicenseFilePath => thirdPartyLicenseFilePath.GetFullPath(); + + internal static bool IsExchangingDeviceKey = false; + + internal static string? ExchangeDeviceKeyCode; + + internal static int DevicesServerPort = -1; + + internal static int PluginsServerPort = -1; + + internal static bool Running = true; + + internal static bool Exiting = false; + + internal static bool Restarting = false; + + internal static bool EnsureExiting = false; + + internal static bool IsMainMachine = false; + + internal static string? MainMachineAddress; + + internal static int MainMachinePort = -1; + + internal static bool SkipNetworkSystemOnStartup = false; + + internal static DateTime ServerBuildTime = new(); + + internal const string ApiGetAnnouncements = "get-announcements.php"; + + internal const string ApiGetAnnouncement = "get-announcement.php"; + + internal static string KitXIconBase64 = string.Empty; + + internal static bool IsSingleProcessStartMode = true; + + internal static bool EnabledConfigFileHotReload = true; +} diff --git a/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs b/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs new file mode 100644 index 00000000..a3fd4798 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs @@ -0,0 +1,163 @@ +using Microsoft.Extensions.DependencyInjection; +using KitX.Core.Contract.Activity; +using KitX.Core.Contract.Announcement; +using KitX.Core.Contract.Configuration; +using KitX.Core.Contract.Device; +using KitX.Core.Contract.FileWatcher; +using KitX.Core.Contract.Hotkey; +using KitX.Core.Contract.Plugin; +using KitX.Core.Contract.Security; +using KitX.Core.Contract.Statistics; +using KitX.Core.Contract.Tasks; +using KitX.Core.Contract.Workflow; +using KitX.Core.Contract.Event; +using KitX.Core.Activity; +using KitX.Core.Announcement; +using KitX.Core.Configuration; +using KitX.Core.Device; +using KitX.Core.FileWatcher; +using KitX.Core.Hotkey; +using KitX.Core.Plugin; +using KitX.Core.Security; +using KitX.Core.Statistics; +using KitX.Core.Task; +using KitX.Core.Workflow; +using KitX.Core.Event; +using Serilog; + +namespace KitX.Core.DI; + +/// +/// Extension methods for configuring KitX Core services in the DI container +/// +public static class CoreServiceCollectionExtensions +{ + /// + /// Adds all KitX Core services to the dependency injection container + /// + /// The service collection to add services to + /// The service collection for chaining + public static IServiceCollection AddCoreServices(this IServiceCollection services) + { + Log.Information("AddCoreServices started..."); + + // Register all core services as singletons + // These services maintain state and should have only one instance throughout the application lifetime + + // Configuration Services + Log.Information("Registering IConfigService..."); + services.AddSingleton(provider => + { + var service = ConfigManager.Instance; + return service; + }); + + // Security Services + Log.Information("Registering ISecurityService..."); + services.AddSingleton(provider => + { + var service = SecurityManager.Instance; + return service; + }); + + // Plugin Services + Log.Information("Registering IPluginService..."); + services.AddSingleton(provider => + { + var service = PluginsManager.Instance; + return service; + }); + + // Workflow Services + Log.Information("Registering IWorkflowService..."); + services.AddSingleton(provider => + { + var service = WorkflowScriptService.Instance; + return service; + }); + + // Activity Services + Log.Information("Registering IActivityService..."); + services.AddSingleton(provider => + { + var service = ActivityManager.Instance; + return service; + }); + + // Statistics Services + Log.Information("Registering IStatisticsService..."); + services.AddSingleton(provider => + { + var service = StatisticsManager.Instance; + return service; + }); + + // Task Services + Log.Information("Registering ITasksService..."); + services.AddSingleton(provider => + { + var service = TasksManager.Instance; + return service; + }); + + // File Watcher Services + Log.Information("Registering IFileWatcherService..."); + services.AddSingleton(provider => + { + var service = FileWatcherManager.Instance; + return service; + }); + + // Hotkey Services + Log.Information("Registering IKeyHookService..."); + services.AddSingleton(provider => + { + var service = KeyHookManager.Instance; + return service; + }); + + // Event Services + Log.Information("Registering IEventService..."); + services.AddSingleton(provider => + { + var service = EventService.Instance; + return service; + }); + + // Phase 5: Device and Network Services + Log.Information("Registering IDeviceDiscoveryService..."); + services.AddSingleton(provider => + { + var service = DevicesDiscoveryServer.Instance; + return service; + }); + + Log.Information("Registering IDeviceServer..."); + services.AddSingleton(provider => + { + var service = DevicesServer.Instance; + return service; + }); + + Log.Information("Registering IPluginServer..."); + services.AddSingleton(provider => + { + var service = PluginsServer.Instance; + return service; + }); + + // Phase 5: Announcement Service + Log.Information("Registering IAnnouncementService..."); + services.AddSingleton(provider => + { + var configService = provider.GetService(); + var service = configService != null + ? new AnnouncementManager(configService) + : AnnouncementManager.Instance; + return service; + }); + + Log.Information("AddCoreServices completed."); + return services; + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Device/DeviceCase.cs b/KitX Clients/KitX Core/KitX.Core/Device/DeviceCase.cs new file mode 100644 index 00000000..a594bc4b --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Device/DeviceCase.cs @@ -0,0 +1,76 @@ +using KitX.Core.Configuration; +using System; +using KitX.Core.Contract.Configuration; +using KitX.Core.Contract.Device; +using KitX.Core.Contract.Security; +using KitX.Core.Security; +using KitX.Shared.CSharp.Device; + +namespace KitX.Core.Device; + +/// +/// Device case implementation +/// Phase 6.5: Aligned with legacy DeviceCase functionality +/// +public class DeviceCase : IDeviceCase +{ + private readonly IConfigService _configService; + private readonly ISecurityService _securityService; + private readonly DevicesServer _devicesServer; + + /// + /// Creates a new device case + /// + public DeviceCase() : this(new DeviceInfo()) { } + + /// + /// Creates a new device case with device info + /// + /// Device information + public DeviceCase(DeviceInfo deviceInfo) + { + DeviceInfo = deviceInfo; + _configService = ConfigManager.Instance; + _securityService = SecurityManager.Instance; + _devicesServer = DevicesServer.Instance; + } + + /// + public DeviceInfo DeviceInfo { get; set; } + + /// + public bool IsAuthorized => _securityService.IsDeviceAuthorized(DeviceInfo.Device); + + /// + public bool IsMainDevice => DeviceInfo?.IsMainDevice ?? false; + + /// + public bool IsOnline => !IsOffline(); + + /// + public DateTime LastSeen => DeviceInfo?.SendTime ?? DateTime.UtcNow; + + /// + /// Gets a value indicating whether the device is currently signed in + /// + public bool IsConnected => _devicesServer.IsDeviceSignedIn(DeviceInfo.Device) || ConnectionToken is not null; + + /// + /// Gets a value indicating whether this is the current device + /// + public bool IsCurrentDevice => DeviceInfo.IsCurrentDevice(DevicesDiscoveryServer.Instance.DefaultDeviceInfo); + + /// + /// Connection token for authenticated communication + /// + public string? ConnectionToken { get; set; } + + /// + /// Checks if the device is offline (not seen within TTL period) + /// + private bool IsOffline() + { + var ttl = TimeSpan.FromSeconds(_configService.AppConfig.Web.DeviceInfoTTLSeconds); + return DeviceInfo.IsOffline(_configService.AppConfig.Web.DeviceInfoTTLSeconds); + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Device/DevicesDiscoveryServer.cs b/KitX Clients/KitX Core/KitX.Core/Device/DevicesDiscoveryServer.cs new file mode 100644 index 00000000..fac8300f --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Device/DevicesDiscoveryServer.cs @@ -0,0 +1,416 @@ +using KitX.Core.Configuration; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Sockets; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using CTask = System.Threading.Tasks.Task; +using KitX.Core.Contract.Configuration; +using KitX.Core.Contract.Device; +using KitX.Shared.CSharp.Device; +using Serilog; + +namespace KitX.Core.Device; + +/// +/// Device discovery server for UDP broadcast +/// +public class DevicesDiscoveryServer : IDeviceDiscoveryService +{ + private static DevicesDiscoveryServer? _instance; + + /// + /// Gets the singleton instance + /// + internal static DevicesDiscoveryServer Instance => _instance ??= new(); + + private readonly IConfigService _configService; + private UdpClient? _udpSender; + private UdpClient? _udpReceiver; + private System.Timers.Timer? _udpSendTimer; + private readonly List _supportedNetworkInterfacesIndexes = new(); + private bool _disposed; + private ServerStatus _status = ServerStatus.Pending; + private int _deviceInfoUpdatedTimes = 0; + private int _lastTimeToOSVersionUpdated = 0; + + /// + /// Gets the service status + /// + public ServerStatus Status => _status; + + /// + /// Gets or sets the port + /// + public int? Port { get; private set; } + + /// + /// Configured port for the server + /// + private int? _configuredPort; + + /// + /// Configures the port for the server + /// + /// The port number + public void ConfigurePort(int port) + { + _configuredPort = port is >= 0 and <= 65535 ? port : null; + } + + /// + /// Request to close the server + /// + public bool CloseDevicesDiscoveryServerRequest { get; internal set; } + + /// + /// Queue of messages to broadcast + /// + public Queue Messages2BroadCast { get; } = new(); + + /// + /// Default device information + /// + public DeviceInfo DefaultDeviceInfo { get; private set; } + + /// + /// Event raised when a device is discovered + /// + public event EventHandler? DeviceDiscovered; + + /// + /// Event raised when a device goes offline + /// + public event EventHandler? DeviceOffline; + + /// + /// Private constructor + /// + private DevicesDiscoveryServer() + { + _configService = ConfigManager.Instance; // Will be injected via DI in production + DefaultDeviceInfo = NetworkHelper.GetDeviceInfo(); + + // Note: DevicesOrganizer.Run() should be called after services are fully initialized + // to avoid blocking during DI container setup + } + + /// + /// Starts the device discovery service + /// + /// The service instance + public IDeviceDiscoveryService Run() + { + if (_status != ServerStatus.Pending) + return this; + + _status = ServerStatus.Starting; + + Initialize(); + + // Read configuration from IConfigService + var udpPortSend = _configService.AppConfig.Web.UdpPortSend; + var udpPortReceive = _configService.AppConfig.Web.UdpPortReceive; + var udpBroadcastAddress = _configService.AppConfig.Web.UdpBroadcastAddress; + + Port = udpPortSend; + + _udpSender = new UdpClient(udpPortSend, AddressFamily.InterNetwork) + { + EnableBroadcast = true, + MulticastLoopback = true, + }; + + _udpReceiver = new UdpClient(new IPEndPoint(IPAddress.Any, udpPortReceive)); + + CTask.Run(() => + { + try + { + FindSupportNetworkInterfaces( + [_udpSender, _udpReceiver], + IPAddress.Parse(udpBroadcastAddress) + ); + } + catch (Exception ex) + { + const string location = $"{nameof(DevicesDiscoveryServer)}.{nameof(Run)}"; + Log.Warning(ex, $"In {location}: {ex.Message}"); + } + }); + + CTask.Run(MultiDevicesBroadCastSend); + CTask.Run(MultiDevicesBroadCastReceive); + + _status = ServerStatus.Running; + + return this; + } + + /// + /// Stops the device discovery service + /// + public void Stop() + { + if (_status != ServerStatus.Running) + return; + + _status = ServerStatus.Stopping; + CloseDevicesDiscoveryServerRequest = true; + + CTask.Run(async () => + { + await CTask.Delay(1000); // Wait for threads to finish + _status = ServerStatus.Pending; + }); + } + + private void Initialize() + { + _disposed = false; + CloseDevicesDiscoveryServerRequest = false; + _supportedNetworkInterfacesIndexes.Clear(); + Messages2BroadCast.Clear(); + DefaultDeviceInfo = NetworkHelper.GetDeviceInfo(); + _deviceInfoUpdatedTimes = 0; + _lastTimeToOSVersionUpdated = 0; + } + + private void FindSupportNetworkInterfaces(List clients, IPAddress multicastAddress) + { + var multicastGroupJoinedInterfacesCount = 0; + + foreach (var adapter in NetworkInterface.GetAllNetworkInterfaces()) + { + var adapterProperties = adapter.GetIPProperties(); + + if (adapterProperties is null) + continue; + + if (!CheckNetworkInterface(adapter, adapterProperties)) + continue; + + var unicastIPAddresses = adapterProperties.UnicastAddresses; + + if (unicastIPAddresses is null) + continue; + + var p = adapterProperties.GetIPv4Properties(); + + if (p is null) + continue; + + _supportedNetworkInterfacesIndexes.Add(IPAddress.HostToNetworkOrder(p.Index)); + + foreach (var ipAddress in unicastIPAddresses.Select(x => x.Address).Where(x => x.AddressFamily == AddressFamily.InterNetwork)) + { + try + { + foreach (var udpClient in clients) + udpClient?.JoinMulticastGroup(multicastAddress, ipAddress); + + ++multicastGroupJoinedInterfacesCount; + } + catch (Exception ex) + { + const string location = $"{nameof(DevicesDiscoveryServer)}.{nameof(FindSupportNetworkInterfaces)}"; + + Log.Error(ex, $"In {location}: {ex.Message}"); + } + } + } + + Log.Information($"Find {_supportedNetworkInterfacesIndexes.Count} supported network interfaces."); + Log.Information($"Joined {multicastGroupJoinedInterfacesCount} multicast groups."); + } + + private bool CheckNetworkInterface(NetworkInterface adapter, IPInterfaceProperties adapterProperties) + { + // Filter for operational, supported network interfaces + return adapter.OperationalStatus == OperationalStatus.Up && + adapter.NetworkInterfaceType != NetworkInterfaceType.Loopback && + adapter.Supports(NetworkInterfaceComponent.IPv4); + } + + private void UpdateDefaultDeviceInfo() + { + DefaultDeviceInfo.IsMainDevice = ConstantTable.IsMainMachine; + DefaultDeviceInfo.SendTime = DateTime.UtcNow; + DefaultDeviceInfo.Device.ResetIPv4(NetworkHelper.GetInterNetworkIPv4()) + .ResetIPv6(NetworkHelper.GetInterNetworkIPv6()); + DefaultDeviceInfo.PluginsServerPort = ConstantTable.PluginsServerPort; + DefaultDeviceInfo.PluginsCount = 0; // TODO: Will be updated from PluginsManager + DefaultDeviceInfo.IsMainDevice = ConstantTable.IsMainMachine; + DefaultDeviceInfo.DevicesServerPort = ConstantTable.DevicesServerPort; + DefaultDeviceInfo.DevicesServerBuildTime = ConstantTable.ServerBuildTime; + + // Update OS version periodically + if (_lastTimeToOSVersionUpdated > _configService.AppConfig.IO.OperatingSystemVersionUpdateInterval) + { + _lastTimeToOSVersionUpdated = 0; + DefaultDeviceInfo.DeviceOSVersion = NetworkHelper.TryGetOsVersionString() ?? ""; + } + + ++_deviceInfoUpdatedTimes; + ++_lastTimeToOSVersionUpdated; + + if (_deviceInfoUpdatedTimes < 0) + _deviceInfoUpdatedTimes = 0; + } + + private void MultiDevicesBroadCastSend() + { + const string location = $"{nameof(DevicesDiscoveryServer)}.{nameof(MultiDevicesBroadCastSend)}"; + + var udpPortReceive = _configService.AppConfig.Web.UdpPortReceive; + var udpBroadcastAddress = _configService.AppConfig.Web.UdpBroadcastAddress; + var udpSendFrequency = _configService.AppConfig.Web.UdpSendFrequency; + + var multicast = new IPEndPoint( + IPAddress.Parse(udpBroadcastAddress), + udpPortReceive + ); + + _udpSender?.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); + + var erroredInterfacesIndexes = new List(); + var erroredInterfacesIndexesTTL = 60; + + _udpSendTimer = new System.Timers.Timer { Interval = udpSendFrequency, AutoReset = true }; + + _udpSendTimer.Elapsed += (_, _) => + { + var closingRequest = CloseDevicesDiscoveryServerRequest; + + --erroredInterfacesIndexesTTL; + + if (erroredInterfacesIndexesTTL <= 0) + { + erroredInterfacesIndexesTTL = 60; + erroredInterfacesIndexes.Clear(); + } + + UpdateDefaultDeviceInfo(); + + if (closingRequest) + DefaultDeviceInfo.SendTime -= TimeSpan.FromSeconds(20); + + var sendText = JsonSerializer.Serialize(DefaultDeviceInfo); + var sendBytes = System.Text.Encoding.UTF8.GetBytes(sendText); + + foreach (var item in _supportedNetworkInterfacesIndexes) + { + if (erroredInterfacesIndexes.Contains(item)) + continue; + + try + { + _udpSender?.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastInterface, item); + _udpSender?.Send(sendBytes, sendBytes.Length, multicast); + + while (Messages2BroadCast.Count > 0) + { + var messageBytes = System.Text.Encoding.UTF8.GetBytes(Messages2BroadCast.Dequeue()); + _udpSender?.Send(messageBytes, messageBytes.Length, multicast); + } + } + catch (Exception ex) + { + if (!erroredInterfacesIndexes.Contains(item)) + erroredInterfacesIndexes.Add(item); + + Log.Warning(ex, $"In {location}: Errored interface index: {item}, recorded."); + } + } + + if (closingRequest) + { + _udpSendTimer?.Stop(); + _udpSendTimer?.Close(); + _udpSender?.Close(); + _udpReceiver?.Close(); + CloseDevicesDiscoveryServerRequest = false; + } + }; + + _udpSendTimer.Start(); + } + + private void MultiDevicesBroadCastReceive() + { + const string location = $"{nameof(DevicesDiscoveryServer)}.{nameof(MultiDevicesBroadCastReceive)}"; + + var multicast = new IPEndPoint(IPAddress.Any, 0); + + _udpReceiver?.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); + + var thread = new Thread(async () => + { + try + { + while (!CloseDevicesDiscoveryServerRequest) + { + var bytes = _udpReceiver?.Receive(ref multicast); + var client = $"{multicast.Address}:{multicast.Port}"; + + if (bytes is null) + continue; + + var result = System.Text.Encoding.UTF8.GetString(bytes); + + Log.Information($"UDP From: {client, -21}, Receive: {result}"); + + try + { + var info = JsonSerializer.Deserialize(result); + + if (info is not null) + { + DeviceDiscovered?.Invoke(this, new DeviceDiscoveredEventArgs + { + DeviceInfo = info + }); + } + } + catch (Exception ex) + { + Log.Warning(ex, $"When trying to deserialize `{result}`"); + } + } + + _status = ServerStatus.Pending; + } + catch (Exception e) + { + Log.Error(e, $"In {location}: {e.Message}"); + _status = ServerStatus.Errored; + } + + await CTask.Run(() => Stop()); + }); + + thread.Start(); + } + + /// + /// Disposes the server + /// + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + + CloseDevicesDiscoveryServerRequest = false; + + _udpSender?.Dispose(); + _udpReceiver?.Dispose(); + + GC.Collect(); + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Device/DevicesOrganizer.cs b/KitX Clients/KitX Core/KitX.Core/Device/DevicesOrganizer.cs new file mode 100644 index 00000000..c74bcb00 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Device/DevicesOrganizer.cs @@ -0,0 +1,344 @@ +using KitX.Core.Configuration; +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading; +using KitX.Core.Contract.Configuration; +using KitX.Core.Contract.Device; +using KitX.Core.Contract.Event; +using KitX.Core.Event; +using KitX.Shared.CSharp.Device; +using Serilog; +using Timer = System.Timers.Timer; + +namespace KitX.Core.Device; + +/// +/// Devices organizer for managing discovered devices +/// Phase 6.5: Aligned with legacy DevicesOrganizer functionality +/// +public class DevicesOrganizer : IDevicesOrganizer +{ + private static DevicesOrganizer? _instance; + + /// + /// Gets the singleton instance + /// + internal static DevicesOrganizer Instance => _instance ??= new(); + + private readonly IConfigService _configService; + private readonly IEventService _eventService; + private readonly object _receivedDeviceInfo4WatchLock = new(); + private readonly Queue _deviceInfosQueue = new(); + private readonly object _addDeviceCardLock = new(); + private bool _keepCheckAndRemoveTaskRunning = false; + private List? _receivedDeviceInfo4Watch; + + /// + /// Event raised when a device is discovered + /// + public event EventHandler? DeviceDiscovered; + + /// + /// Event raised when a device goes offline + /// + public event EventHandler? DeviceOffline; + + private DevicesOrganizer() + { + _configService = ConfigManager.Instance; + _eventService = EventService.Instance; + Initialize(); + } + + /// + /// Runs the devices organizer + /// + public static void Run() + { + _instance = Instance; + } + + /// + /// Initializes the devices organizer + /// + private void Initialize() + { + InitEvents(); + + KeepCheckAndRemove(); + + ObserveMainDevice(); + } + + /// + /// Initializes event subscriptions + /// + private void InitEvents() + { + // Subscribe to device discovery events from DevicesDiscoveryServer + DevicesDiscoveryServer.Instance.DeviceDiscovered += (_, args) => + { + if (args.DeviceInfo is null) return; + + _deviceInfosQueue.Enqueue(args.DeviceInfo); + + lock (_receivedDeviceInfo4WatchLock) + { + _receivedDeviceInfo4Watch?.Add(args.DeviceInfo); + } + + // Check for main device changes + if (args.DeviceInfo.IsMainDevice && args.DeviceInfo.DevicesServerBuildTime < ConstantTable.ServerBuildTime) + { + ConstantTable.IsMainMachine = false; + + ObserveMainDevice(); + + Log.Information( + new StringBuilder() + .AppendLine("Watched earlier built server.") + .AppendLine($"DevicesServerAddress: {args.DeviceInfo.Device.IPv4}:{args.DeviceInfo.DevicesServerPort}") + .AppendLine($"DevicesServerBuildTime: {args.DeviceInfo.DevicesServerBuildTime}") + .ToString() + ); + } + }; + } + + /// + /// Updates the source and adds device cards + /// + /// The device information + public void UpdateSourceAndAddCards(DeviceInfo deviceInfo) + { + lock (_addDeviceCardLock) + { + // Trigger event to notify UI layer + DeviceDiscovered?.Invoke(this, new DeviceDiscoveredEventArgs + { + DeviceInfo = deviceInfo + }); + } + } + + /// + /// Keeps checking and removing offline devices + /// + private void KeepCheckAndRemove() + { + const string location = $"{nameof(DevicesOrganizer)}.{nameof(KeepCheckAndRemove)}"; + + var timer = new Timer + { + Interval = _configService.AppConfig.Web.DevicesViewRefreshDelay, + AutoReset = true + }; + + timer.Elapsed += (_, _) => + { + try + { + if (_keepCheckAndRemoveTaskRunning) + { + Log.Information($"In {location}: Timer elapsed and skip task."); + } + else + { + _keepCheckAndRemoveTaskRunning = true; + + UpdateSourceAndAddCards(); + + if (_configService.AppConfig.Web.DisableRemovingOfflineDeviceCard == false) + RemoveOfflineCards(); + + // TODO: Implement MoveSelfCardToFirst if needed + + _keepCheckAndRemoveTaskRunning = false; + } + } + catch (Exception ex) + { + Log.Error(ex, $"In {location}: {ex.Message}"); + } + }; + + timer.Start(); + + // Subscribe to config changes via IEventService + _eventService.Subscribe(EventNames.AppConfigChanged, (s, e) => + { + timer.Interval = _configService.AppConfig.Web.DevicesViewRefreshDelay; + }); + } + + /// + /// Updates source and adds cards from queue + /// + private void UpdateSourceAndAddCards() + { + var thisTurnAdded = new List(); + + while (_deviceInfosQueue.Count > 0) + { + var info = _deviceInfosQueue.Dequeue(); + var hashCode = info.GetHashCode(); + + if (thisTurnAdded.Contains(hashCode)) + continue; + + // Trigger event for UI layer to handle deduplication + DeviceDiscovered?.Invoke(this, new DeviceDiscoveredEventArgs + { + DeviceInfo = info + }); + + thisTurnAdded.Add(hashCode); + } + } + + /// + /// Removes offline device cards + /// + private void RemoveOfflineCards() + { + // Trigger offline events for devices that haven't been seen recently + if (_receivedDeviceInfo4Watch is null) return; + + var ttl = TimeSpan.FromSeconds(_configService.AppConfig.Web.DeviceInfoTTLSeconds); + var now = DateTime.UtcNow; + + foreach (var device in _receivedDeviceInfo4Watch) + { + if (now - device.SendTime.ToUniversalTime() > ttl) + { + DeviceOffline?.Invoke(this, new DeviceOfflineEventArgs + { + DeviceId = device.GetHashCode().ToString() + }); + } + } + } + + /// + /// Observes main device in the network + /// + /// Cancellation token + internal void ObserveMainDevice(CancellationToken token = default) + { + const string location = $"{nameof(DevicesOrganizer)}.{nameof(ObserveMainDevice)}"; + + new Thread(() => + { + _receivedDeviceInfo4Watch = []; + + var checkedTime = 0; + var hadMainDevice = false; + var earliestBuiltServerTime = DateTime.UtcNow; + var serverPort = 0; + var serverAddress = string.Empty; + + while (checkedTime < 7 && token.IsCancellationRequested == false) + { + try + { + if (_receivedDeviceInfo4Watch is null) + continue; + + lock (_receivedDeviceInfo4WatchLock) + { + foreach (var item in _receivedDeviceInfo4Watch) + { + if (item.IsMainDevice) + { + if (item.DevicesServerBuildTime.ToUniversalTime() < earliestBuiltServerTime) + { + serverPort = item.DevicesServerPort; + serverAddress = item.Device.IPv4; + } + hadMainDevice = true; + } + } + } + + ++checkedTime; + + Log.Information($"In {location}: Watched for {checkedTime} times."); + + if (checkedTime == 7) + { + _receivedDeviceInfo4Watch?.Clear(); + _receivedDeviceInfo4Watch = null; + + if (token.IsCancellationRequested == false) + WatchingOver(hadMainDevice, serverAddress, serverPort); + } + + Thread.Sleep(1 * 1000); // Sleep 1 second + } + catch (Exception e) + { + _receivedDeviceInfo4Watch?.Clear(); + _receivedDeviceInfo4Watch = null; + + Log.Error(e, $"In {location}: {e.Message} Rewatch."); + + if (token.IsCancellationRequested == false) + ObserveMainDevice(); + + break; + } + } + }).Start(); + } + + /// + /// Called when main device observation is complete + /// + private void WatchingOver(bool foundMainDevice, string serverAddress, int serverPort) + { + const string location = $"{nameof(DevicesOrganizer)}.{nameof(WatchingOver)}"; + + Log.Information( + new StringBuilder() + .Append($"In {location}: ") + .Append($"{nameof(foundMainDevice)} -> {foundMainDevice}") + .Append(", ") + .Append($"{nameof(serverAddress)} -> {serverAddress}") + .Append(", ") + .Append($"{nameof(serverPort)} -> {serverPort}") + .ToString() + ); + + if (foundMainDevice) + { + ConstantTable.MainMachineAddress = serverAddress; + ConstantTable.MainMachinePort = serverPort; + } + else + { + ConstantTable.IsMainMachine = true; + } + } +} + +/// +/// Devices organizer interface +/// +public interface IDevicesOrganizer +{ + /// + /// Updates the source and adds device cards + /// + void UpdateSourceAndAddCards(DeviceInfo deviceInfo); + + /// + /// Event raised when a device is discovered + /// + event EventHandler? DeviceDiscovered; + + /// + /// Event raised when a device goes offline + /// + event EventHandler? DeviceOffline; +} diff --git a/KitX Clients/KitX Core/KitX.Core/Device/DevicesServer.cs b/KitX Clients/KitX Core/KitX.Core/Device/DevicesServer.cs new file mode 100644 index 00000000..42630701 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Device/DevicesServer.cs @@ -0,0 +1,250 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using KitX.Core.Contract.Device; +using KitX.Shared.CSharp.Device; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Serilog; + +namespace KitX.Core.Device; + +/// +/// Device server for HTTP API +/// Phase 5: Simplified implementation using direct WebHostBuilder +/// +public class DevicesServer : IDeviceServer +{ + private static DevicesServer? _instance; + + /// + /// Gets the singleton instance + /// + internal static DevicesServer Instance => _instance ??= new(); + + private readonly Dictionary _signedDeviceTokens = new(); + private ServerStatus _status = ServerStatus.Pending; + private IWebHost? _host; + private int? _configuredPort; + + /// + /// Gets the service status + /// + public ServerStatus Status => _status; + + /// + /// Gets or sets the port + /// + public int? Port { get; private set; } + + /// + /// Configures the port for the server + /// + /// The port number + public void ConfigurePort(int port) + { + _configuredPort = port is >= 0 and <= 65535 ? port : 8888; + } + + /// + /// Starts the device server + /// + /// The server instance + public IDeviceServer Run() + { + if (_status != ServerStatus.Pending) + return this; + + _status = ServerStatus.Starting; + + var port = _configuredPort ?? 8888; + + try + { + // Create and start the ASP.NET Core web host + _host = new WebHostBuilder() + .ConfigureServices(services => + { + // Add routing services + services.AddRouting(); + + // Add core services + services.AddSingleton(_signedDeviceTokens); + }) + .UseKestrel() + .UseUrls($"http://0.0.0.0:{port}") + .Configure(app => + { + app.UseRouting(); + app.UseEndpoints(endpoints => + { + // Basic health check endpoint + endpoints.MapGet("/", async context => + { + await context.Response.WriteAsync("KitX DevicesServer is running"); + }); + + // Device authentication endpoint (placeholder for future implementation) + endpoints.MapPost("/api/device/auth", async context => + { + // TODO: Implement device authentication logic + await context.Response.WriteAsync("{\"status\":\"pending\"}"); + }); + }); + }) + .Build(); + + // Start the host in a background thread + var hostThread = new Thread(async () => + { + try + { + await _host.StartAsync(); + + // Get the actual port + var server = _host.Services.GetService(); + var addresses = server?.Features.Get()?.Addresses; + + if (addresses is not null && addresses.Count > 0) + { + var uri = new Uri(addresses.First()); + Port = uri.Port; + + Log.Information($"DevicesServer started on port {Port}"); + } + + _status = ServerStatus.Running; + } + catch (Exception ex) + { + Log.Error(ex, $"Failed to start DevicesServer: {ex.Message}"); + _status = ServerStatus.Errored; + } + }) + { + IsBackground = true + }; + + hostThread.Start(); + + // Wait for server to start + var timeout = 0; + while (_status == ServerStatus.Starting && timeout < 50) // 5 seconds timeout + { + Thread.Sleep(100); + timeout++; + } + + if (_status != ServerStatus.Running) + { + Log.Warning("DevicesServer start timed out or failed"); + } + } + catch (Exception ex) + { + Log.Error(ex, $"Error starting DevicesServer: {ex.Message}"); + _status = ServerStatus.Errored; + } + + return this; + } + + /// + /// Stops the device server + /// + public void Stop() + { + if (_status != ServerStatus.Running) + return; + + _status = ServerStatus.Stopping; + + try + { + if (_host is not null) + { + _host.StopAsync().Wait(TimeSpan.FromSeconds(5)); + _host.Dispose(); + _host = null; + } + + Log.Information("DevicesServer stopped"); + _status = ServerStatus.Pending; + } + catch (Exception ex) + { + Log.Error(ex, $"Error stopping DevicesServer: {ex.Message}"); + _status = ServerStatus.Errored; + } + } + + /// + /// Closes the device server asynchronously (for backward compatibility) + /// + /// Task representing the asynchronous operation + public async System.Threading.Tasks.Task CloseAsync() + { + Stop(); + await System.Threading.Tasks.Task.CompletedTask; + } + + /// + /// Checks if a device token exists + /// + /// The token to check + /// True if the token exists + public bool IsDeviceTokenExist(string token) => _signedDeviceTokens.ContainsValue(token); + + /// + /// Searches for a device by token + /// + /// The token to search for + /// The device locator or null if not found + public DeviceLocator? SearchDeviceByToken(string token) + { + if (!IsDeviceTokenExist(token)) + return null; + + return _signedDeviceTokens.First(x => x.Value.Equals(token)).Key; + } + + /// + /// Checks if a device is signed in + /// + /// The device locator + /// True if the device is signed in + public bool IsDeviceSignedIn(DeviceLocator locator) => _signedDeviceTokens.ContainsKey(locator); + + /// + /// Adds a device token + /// + /// The device locator + /// The token + public void AddDeviceToken(DeviceLocator locator, string token) => _signedDeviceTokens.Add(locator, token); + + /// + /// Signs in a device + /// + /// The device locator + /// The generated token + public string SignInDevice(DeviceLocator locator) + { + var token = Guid.NewGuid().ToString(); + + while (_signedDeviceTokens.ContainsValue(token)) + token = Guid.NewGuid().ToString(); + + if (!_signedDeviceTokens.TryAdd(locator, token)) + _signedDeviceTokens[locator] = token; + + Log.Information($"Device {locator} signed in with token {token}"); + + return token; + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Device/NetworkHelper.cs b/KitX Clients/KitX Core/KitX.Core/Device/NetworkHelper.cs new file mode 100644 index 00000000..2f39920e --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Device/NetworkHelper.cs @@ -0,0 +1,182 @@ +using System; +using System.Linq; +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Sockets; +using KitX.Shared.CSharp.Device; +using Common.BasicHelper.Core; +using Serilog; + +namespace KitX.Core.Device; + +/// +/// Network helper for device discovery and network operations +/// Phase 5: Simplified version without Dashboard dependencies +/// +internal static class NetworkHelper +{ + /// + /// Gets the local IPv4 address + /// + /// IPv4 address or empty string if not found + internal static string GetInterNetworkIPv4() + { + const string location = $"{nameof(NetworkHelper)}.{nameof(GetInterNetworkIPv4)}"; + + try + { + var host = Dns.GetHostEntry(Dns.GetHostName()); + var search = host.AddressList + .Where(ip => + ip.AddressFamily == AddressFamily.InterNetwork && + !ip.ToString().Equals("127.0.0.1") && + IsInterNetworkAddressV4(ip)) + .FirstOrDefault(); + + var result = search?.ToString(); + + return result ?? string.Empty; + } + catch (Exception ex) + { + Log.Warning(ex, $"In {location}: {ex.Message}"); + return string.Empty; + } + } + + /// + /// Gets the local IPv6 address + /// + /// IPv6 address or empty string if not found + internal static string GetInterNetworkIPv6() + { + const string location = $"{nameof(NetworkHelper)}.{nameof(GetInterNetworkIPv6)}"; + + try + { + var host = Dns.GetHostEntry(Dns.GetHostName()); + var search = host.AddressList + .Where(ip => ip.AddressFamily == AddressFamily.InterNetworkV6 && !ip.ToString().Equals("::1")) + .FirstOrDefault(); + + var result = search?.ToString(); + + return result ?? string.Empty; + } + catch (Exception ex) + { + Log.Warning(ex, $"In {location}: {ex.Message}"); + return string.Empty; + } + } + + /// + /// Tries to get the device MAC address + /// + /// MAC address or null if not found + internal static string? TryGetDeviceMacAddress() + { + const string location = $"{nameof(NetworkHelper)}.{nameof(TryGetDeviceMacAddress)}"; + + try + { + var ipv4 = GetInterNetworkIPv4(); + + if (string.IsNullOrEmpty(ipv4)) + return null; + + var nic = NetworkInterface.GetAllNetworkInterfaces() + .FirstOrDefault(n => + n.OperationalStatus == OperationalStatus.Up && + (n.NetworkInterfaceType == NetworkInterfaceType.Ethernet || + n.NetworkInterfaceType == NetworkInterfaceType.Wireless80211) && + n.GetIPProperties().UnicastAddresses.Any(x => x.Address.ToString() == ipv4)); + + var result = nic?.GetPhysicalAddress().ToString(); + + // Format MAC address with colons (e.g., "AA:BB:CC:DD:EE:FF") + if (!string.IsNullOrEmpty(result) && result.Length == 12) + { + return string.Join(":", result.Chunk(2).Select(c => new string(c))); + } + + return result; + } + catch (Exception ex) + { + Log.Warning(ex, $"In {location}: {ex.Message}"); + return null; + } + } + + /// + /// Tries to get the OS version string + /// + /// OS version string or default if not found + internal static string? TryGetOsVersionString() + { + const string location = $"{nameof(NetworkHelper)}.{nameof(TryGetOsVersionString)}"; + + var result = Environment.OSVersion.VersionString; + + try + { + var osType = OperatingSystemHelper.GetOSType(); + + // For now, return the basic OS version string + // TODO: Implement Linux/MacOS specific version detection if needed + } + catch (Exception ex) + { + Log.Error(ex, $"In {location}: {ex.Message}"); + } + + return result; + } + + /// + /// Gets device information for discovery + /// + /// Device information + internal static DeviceInfo GetDeviceInfo() + { + var osType = OperatingSystemHelper.GetOSType(); + + return new DeviceInfo + { + Device = new DeviceLocator + { + DeviceName = Environment.MachineName, + MacAddress = TryGetDeviceMacAddress() ?? "", + IPv4 = GetInterNetworkIPv4(), + IPv6 = GetInterNetworkIPv6(), + }, + IsMainDevice = false, // Will be set by DevicesOrganizer + SendTime = DateTime.UtcNow, + DeviceOSType = osType, + DeviceOSVersion = TryGetOsVersionString() ?? "", + PluginsServerPort = 7777, // Default port + DevicesServerPort = 8888, // Default port + DevicesServerBuildTime = DateTime.Now, + PluginsCount = 0, // Will be updated from PluginsManager + }; + } + + /// + /// Checks if an IP address is an internal/private network address + /// + /// IP address to check + /// True if it's an internal network address + private static bool IsInterNetworkAddressV4(IPAddress address) + { + var bytes = address.GetAddressBytes(); + + return bytes[0] switch + { + 10 => true, // 10.0.0.0/8 + 172 when bytes[1] >= 16 && bytes[1] <= 31 => true, // 172.16.0.0/12 + 192 when bytes[1] == 168 => true, // 192.168.0.0/16 + _ => false, + }; + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Device/OperatingSystemHelper.cs b/KitX Clients/KitX Core/KitX.Core/Device/OperatingSystemHelper.cs new file mode 100644 index 00000000..e3ce96ce --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Device/OperatingSystemHelper.cs @@ -0,0 +1,41 @@ +using System; +using KitX.Shared.CSharp.Device; + +namespace KitX.Core.Device; + +/// +/// Operating system utilities for Core +/// Phase 5: Simplified version without UI dependencies +/// +internal static class OperatingSystemHelper +{ + /// + /// Gets the current operating system type + /// + /// Operating system type + public static OperatingSystems GetOSType() + { + if (OperatingSystem.IsAndroid()) + return OperatingSystems.Android; + if (OperatingSystem.IsBrowser()) + return OperatingSystems.Browser; + if (OperatingSystem.IsFreeBSD()) + return OperatingSystems.FreeBSD; + if (OperatingSystem.IsIOS()) + return OperatingSystems.IOS; + if (OperatingSystem.IsLinux()) + return OperatingSystems.Linux; + if (OperatingSystem.IsMacCatalyst()) + return OperatingSystems.MacCatalyst; + if (OperatingSystem.IsMacOS()) + return OperatingSystems.MacOS; + if (OperatingSystem.IsTvOS()) + return OperatingSystems.TvOS; + if (OperatingSystem.IsWatchOS()) + return OperatingSystems.WatchOS; + if (OperatingSystem.IsWindows()) + return OperatingSystems.Windows; + + return OperatingSystems.Unknown; + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Device/PluginsServer.cs b/KitX Clients/KitX Core/KitX.Core/Device/PluginsServer.cs new file mode 100644 index 00000000..c96fe859 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Device/PluginsServer.cs @@ -0,0 +1,459 @@ +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Fleck; +using KitX.Core.Contract.Plugin; +using KitX.Shared.CSharp.Plugin; +using Serilog; +using CTask = System.Threading.Tasks.Task; + +namespace KitX.Core.Device; + +/// +/// Plugins server for WebSocket connections +/// +public class PluginsServer : IPluginServer +{ + private static PluginsServer? _instance; + + /// + /// Gets the singleton instance + /// + internal static PluginsServer Instance => _instance ??= new(); + + private WebSocketServer? _server; + private readonly List _connections = new(); + private ServerStatus _status = ServerStatus.Pending; + + /// + /// Gets the service status + /// + public ServerStatus Status => _status; + + /// + /// Gets or sets the port + /// + public int? Port { get; private set; } + + /// + /// Event raised when server port changes + /// + public event EventHandler? PortChanged; + + /// + /// Gets the list of plugin connections + /// + public IReadOnlyList Connections => _connections.AsReadOnly(); + + /// + /// Event raised when a plugin connects + /// + public event EventHandler? PluginConnected; + + /// + /// Event raised when a plugin disconnects + /// + public event EventHandler? PluginDisconnected; + + /// + /// Event raised when a plugin message is received + /// + public event EventHandler? PluginMessageReceived; + + /// + /// Event raised when a plugin registers with the server (interface implementation) + /// + public event EventHandler? PluginRegistered; + + /// + /// Event raised when a plugin unregisters/disconnects from the server (interface implementation) + /// + public event EventHandler? PluginUnregistered; + + /// + /// Private constructor + /// + private PluginsServer() { } + + /// + /// Initializes the server + /// + private void InitializeServer() + { + var port = 7777; + + port = port is >= 0 and <= 65535 ? port : 0; + + _server ??= new WebSocketServer($"ws://0.0.0.0:{port}"); + } + + /// + /// Runs the plugins server + /// + /// The server instance + public IPluginServer Run() + { + if (_status != ServerStatus.Pending) + return this; + + _status = ServerStatus.Starting; + + InitializeServer(); + + _server!.Start(socket => + { + var connectionId = socket.ConnectionInfo.Path.Trim('/'); + + if (RegexToVerifyConnectionId().IsMatch(connectionId) == false) + { + socket.Send("Invalid connection id."); + socket.Close(); + return; + } + + var connection = new PluginConnection(socket, connectionId); + _connections.Add(connection); + + connection.MessageReceived += (sender, message) => + { + try + { + var kwc = System.Text.Json.JsonSerializer.Deserialize(message); + if (kwc?.Content is not null) + { + var cmd = System.Text.Json.JsonSerializer.Deserialize(kwc.Content); + if (cmd.Request == KitX.Shared.CSharp.WebCommand.Infos.CommandRequestInfo.RegisterPlugin) + { + var body = System.Text.Encoding.UTF8.GetString(cmd.Body.AsSpan(0, cmd.BodyLength).ToArray()); + var pluginInfo = System.Text.Json.JsonSerializer.Deserialize(body); + if (pluginInfo is not null) + { + pluginInfo.Tags ??= new(); + pluginInfo.Tags[nameof(PluginConnection.ConnectionId)] = connectionId; + pluginInfo.Tags["JoinTime"] = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss(FF)"); + connection.PluginInfo = pluginInfo; + PluginRegistered?.Invoke(this, new PluginRegisteredEventArgs + { + PluginInfo = pluginInfo + }); + } + } + } + } + catch (Exception ex) + { + Log.Warning(ex, "Error handling plugin message"); + } + + PluginMessageReceived?.Invoke(this, new PluginMessageReceivedEventArgs + { + ConnectionId = connectionId, + Message = message + }); + }; + + connection.Closed += (sender, args) => + { + _connections.Remove(connection); + + // Trigger PluginUnregistered if this connection had a registered plugin + if (connection.PluginInfo is not null) + { + PluginUnregistered?.Invoke(this, new PluginUnregisteredEventArgs + { + PluginInfo = connection.PluginInfo + }); + } + + PluginDisconnected?.Invoke(this, new PluginDisconnectedEventArgs + { + ConnectionId = connectionId + }); + }; + + connection.Initialize(); + + PluginConnected?.Invoke(this, new PluginConnectedEventArgs + { + ConnectionId = connectionId + }); + }); + + Port = _server!.Port; + + Log.Information($"PluginsServer started on port {Port}"); + + PortChanged?.Invoke(this, Port ?? 0); + + _status = ServerStatus.Running; + + return this; + } + + /// + /// Finds a connection by connection ID + /// + /// The connection ID + /// The plugin connection or null if not found + public IPluginConnection? FindConnection(string connectionId) => + _connections.FirstOrDefault(x => x.ConnectionId?.Equals(connectionId) ?? false); + + /// + /// Finds a connector for a specific plugin (implementation of IPluginServer) + /// + /// The plugin info + /// The plugin connector or null if not found + public IPluginConnector? FindConnector(PluginInfo pluginInfo) + { + // Return null for now - actual implementation would find by plugin info + return null; + } + + /// + /// Finds a connection by plugin info + /// + /// The plugin info + /// The plugin connection or null if not found + public IPluginConnection? FindConnection(PluginInfo pluginInfo) + { + return _connections.FirstOrDefault(x => x.PluginInfo is not null && x.PluginInfo.Equals(pluginInfo)); + } + + /// + /// Stops the plugin server (implementation of IPluginServer) + /// + public void Stop() + { + if (_status != ServerStatus.Running) + return; + + _status = ServerStatus.Stopping; + + try + { + _server?.Dispose(); + _server = null; + + foreach (var connection in _connections) + { + connection.CloseAsync().Wait(); + } + + _connections.Clear(); + + Log.Information("PluginsServer stopped"); + _status = ServerStatus.Pending; + } + catch (Exception ex) + { + Log.Error(ex, "Error stopping PluginsServer"); + _status = ServerStatus.Errored; + } + } + + /// + /// Closes the plugins server + /// + public async Task Close() + { + await CTask.Run(() => + { + CTask.WaitAll(_connections.Select(c => c.CloseAsync()).ToArray()); + + _connections.Clear(); + + _server?.Dispose(); + + _server = null; + + _status = ServerStatus.Pending; + }); + + return this; + } + + /// + /// Regular expression to verify connection ID (GUID format) + /// + private static Regex RegexToVerifyConnectionId() => + new(@"^[{]?[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}[}]?$"); +} + +/// +/// Plugin connection interface +/// +public interface IPluginConnection +{ + /// + /// Gets the connection ID + /// + string? ConnectionId { get; } + + /// + /// Gets or sets the plugin info + /// + PluginInfo? PluginInfo { get; set; } + + /// + /// Gets the connection status + /// + ServerStatus Status { get; } + + /// + /// Event raised when a message is received + /// + event EventHandler? MessageReceived; + + /// + /// Event raised when connection is closed + /// + event EventHandler? Closed; + + /// + /// Initializes the connection + /// + void Initialize(); + + /// + /// Sends a message + /// + /// The message to send + void Send(string message); + + /// + /// Closes the connection + /// + CTask CloseAsync(); +} + +/// +/// Plugin connection implementation +/// +public class PluginConnection : IPluginConnection +{ + private readonly IWebSocketConnection _connection; + private ServerStatus _status = ServerStatus.Pending; + + /// + /// Gets the connection ID + /// + public string? ConnectionId { get; private set; } + + /// + /// Gets or sets the plugin info + /// + public PluginInfo? PluginInfo { get; set; } + + /// + /// Gets the connection status + /// + public ServerStatus Status => _status; + + /// + /// Event raised when a message is received + /// + public event EventHandler? MessageReceived; + + /// + /// Event raised when connection is closed + /// + public event EventHandler? Closed; + + /// + /// Constructor + /// + /// The WebSocket connection + /// The connection ID + public PluginConnection(IWebSocketConnection connection, string connectionId) + { + _connection = connection; + ConnectionId = connectionId; + } + + /// + /// Initializes the connection + /// + public void Initialize() + { + _connection.OnOpen = () => + { + _status = ServerStatus.Running; + }; + + _connection.OnMessage = message => + { + MessageReceived?.Invoke(this, message); + }; + + _connection.OnClose = () => + { + _status = ServerStatus.Pending; + Closed?.Invoke(this, EventArgs.Empty); + }; + + _connection.OnError = ex => + { + _status = ServerStatus.Errored; + Serilog.Log.Error(ex, $"PluginConnection error for {ConnectionId}"); + }; + } + + /// + /// Sends a message + /// + /// The message to send + public void Send(string message) + { + _connection.Send(message); + } + + /// + /// Closes the connection + /// + public async CTask CloseAsync() + { + await CTask.Run(() => + { + _connection.Close(); + }); + } +} + +/// +/// Plugin connected event arguments +/// +public class PluginConnectedEventArgs : EventArgs +{ + /// + /// Gets or sets the connection ID + /// + public string? ConnectionId { get; set; } +} + +/// +/// Plugin disconnected event arguments +/// +public class PluginDisconnectedEventArgs : EventArgs +{ + /// + /// Gets or sets the connection ID + /// + public string? ConnectionId { get; set; } +} + +/// +/// Plugin message received event arguments +/// +public class PluginMessageReceivedEventArgs : EventArgs +{ + /// + /// Gets or sets the connection ID + /// + public string? ConnectionId { get; set; } + + /// + /// Gets or sets the message + /// + public string? Message { get; set; } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Device/ServerStatus.cs b/KitX Clients/KitX Core/KitX.Core/Device/ServerStatus.cs new file mode 100644 index 00000000..680aa77e --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Device/ServerStatus.cs @@ -0,0 +1,13 @@ +namespace KitX.Core.Device; + +/// +/// Server status enumeration +/// +public enum ServerStatus +{ + Pending, + Starting, + Running, + Stopping, + Errored +} diff --git a/KitX Clients/KitX Core/KitX.Core/Event/EventArgs.cs b/KitX Clients/KitX Core/KitX.Core/Event/EventArgs.cs new file mode 100644 index 00000000..5fb53ea3 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Event/EventArgs.cs @@ -0,0 +1,36 @@ +using System; + +namespace KitX.Core.Event; + +/// +/// Event args for port changed events +/// +public class PortChangedEventArgs : EventArgs +{ + /// + /// The new port number + /// + public int Port { get; set; } +} + +/// +/// Event args for device key events +/// +public class DeviceKeyEventArgs : EventArgs +{ + /// + /// The device key + /// + public string Key { get; set; } = string.Empty; +} + +/// +/// Event args for device info events +/// +public class DeviceInfoEventArgs : EventArgs +{ + /// + /// The device information + /// + public KitX.Shared.CSharp.Device.DeviceInfo? DeviceInfo { get; set; } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Event/EventNames.cs b/KitX Clients/KitX Core/KitX.Core/Event/EventNames.cs new file mode 100644 index 00000000..f7695911 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Event/EventNames.cs @@ -0,0 +1,92 @@ +namespace KitX.Core.Event; + +/// +/// Event names for the event bus +/// +public static class EventNames +{ + /// + /// Language changed event + /// + public const string LanguageChanged = "LanguageChanged"; + + /// + /// Greeting text interval updated event + /// + public const string GreetingTextIntervalUpdated = "GreetingTextIntervalUpdated"; + + /// + /// App config changed event + /// + public const string AppConfigChanged = "AppConfigChanged"; + + /// + /// Plugins config changed event + /// + public const string PluginsConfigChanged = "PluginsConfigChanged"; + + /// + /// Mica opacity changed event + /// + public const string MicaOpacityChanged = "MicaOpacityChanged"; + + /// + /// Develop settings changed event + /// + public const string DevelopSettingsChanged = "DevelopSettingsChanged"; + + /// + /// Log config updated event + /// + public const string LogConfigUpdated = "LogConfigUpdated"; + + /// + /// Theme config changed event + /// + public const string ThemeConfigChanged = "ThemeConfigChanged"; + + /// + /// Use statistics changed event + /// + public const string UseStatisticsChanged = "UseStatisticsChanged"; + + /// + /// Devices server port changed event + /// + public const string DevicesServerPortChanged = "DevicesServerPortChanged"; + + /// + /// Plugins server port changed event + /// + public const string PluginsServerPortChanged = "PluginsServerPortChanged"; + + /// + /// Activities updated event + /// + public const string OnActivitiesUpdated = "OnActivitiesUpdated"; + + /// + /// Receive cancel exchanging device key event + /// + public const string OnReceiveCancelExchangingDeviceKey = "OnReceiveCancelExchangingDeviceKey"; + + /// + /// Exiting event + /// + public const string OnExiting = "OnExiting"; + + /// + /// Receiving device info event + /// + public const string OnReceivingDeviceInfo = "OnReceivingDeviceInfo"; + + /// + /// Config hot reloaded event + /// + public const string OnConfigHotReloaded = "OnConfigHotReloaded"; + + /// + /// Accepting device key event + /// + public const string OnAcceptingDeviceKey = "OnAcceptingDeviceKey"; +} diff --git a/KitX Clients/KitX Core/KitX.Core/Event/EventService.cs b/KitX Clients/KitX Core/KitX.Core/Event/EventService.cs new file mode 100644 index 00000000..acb2bcf5 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Event/EventService.cs @@ -0,0 +1,255 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using KitX.Core.Contract.Event; +using KitX.Shared.CSharp.Device; + +namespace KitX.Core.Event; + +/// +/// Event service for global event bus +/// +public class EventService : IEventService +{ + private static EventService? _instance; + + /// + /// Gets the singleton instance + /// + internal static EventService Instance => _instance ??= new(); + + private readonly Dictionary>> _eventHandlers = new(); + + /// + /// Private constructor + /// + private EventService() { } + + /// + /// Subscribes to an event + /// + /// The event name + /// The event handler + public void Subscribe(string eventName, EventHandler handler) + { + if (!_eventHandlers.ContainsKey(eventName)) + { + _eventHandlers[eventName] = new List>(); + } + + _eventHandlers[eventName].Add(handler); + } + + /// + /// Unsubscribes from an event + /// + /// The event name + /// The event handler + public void Unsubscribe(string eventName, EventHandler handler) + { + if (_eventHandlers.TryGetValue(eventName, out var handlers)) + { + handlers.Remove(handler); + } + } + + /// + /// Publishes an event + /// + /// The event name + /// The event arguments + public void Publish(string eventName, EventArgs args) + { + if (_eventHandlers.TryGetValue(eventName, out var handlers)) + { + foreach (var handler in handlers) + { + handler.Invoke(this, args); + } + } + } + + /// + /// Subscribes to a typed event + /// + /// The event args type + /// The event name + /// The event handler + public void Subscribe(string eventName, EventHandler handler) + where TEventArgs : EventArgs + { + if (!_eventHandlers.ContainsKey(eventName)) + { + _eventHandlers[eventName] = new List>(); + } + + _eventHandlers[eventName].Add((sender, args) => + { + if (args is TEventArgs typedArgs) + { + handler(sender, typedArgs); + } + }); + } + + /// + /// Unsubscribes from a typed event + /// + /// The event args type + /// The event name + /// The event handler + public void Unsubscribe(string eventName, EventHandler handler) + where TEventArgs : EventArgs + { + if (_eventHandlers.TryGetValue(eventName, out var handlers)) + { + // Note: Exact handler removal is not supported in this implementation + // The handler wrapper makes exact matching difficult + // Consider using a different approach for production + } + } + + /// + /// Publishes a typed event + /// + /// The event args type + /// The event name + /// The event arguments + public void Publish(string eventName, TEventArgs args) + where TEventArgs : EventArgs + { + Publish(eventName, args); + } + + /// + /// Invokes a static event dynamically (legacy support) + /// + /// The event name + /// Optional parameters + [Obsolete("Use IEventService.Publish with event names instead")] + public static void Invoke(string eventName, object[]? objects = null) + { + var type = typeof(EventService); + + var eventField = type.GetField(eventName, System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic); + + if (eventField is null || !typeof(Delegate).IsAssignableFrom(eventField.FieldType)) + { + throw new ArgumentException($"No event found with the name '{eventName}'.", nameof(eventName)); + } + + var @delegate = eventField.GetValue(null) as Delegate; + + @delegate?.DynamicInvoke(objects); + } + + #region Static Events (Legacy Support) + +#pragma warning disable CS0067 // Event is never used + + /// + /// Language changed event + /// + [Obsolete("Use IEventService with EventNames.LanguageChanged instead")] + public static event Action LanguageChanged = () => { }; + + /// + /// Greeting text interval updated event + /// + [Obsolete("Use IEventService with EventNames.GreetingTextIntervalUpdated instead")] + public static event Action GreetingTextIntervalUpdated = () => { }; + + /// + /// App config changed event + /// + [Obsolete("Use IEventService with EventNames.AppConfigChanged instead")] + public static event Action AppConfigChanged = () => { }; + + /// + /// Plugins config changed event + /// + [Obsolete("Use IEventService with EventNames.PluginsConfigChanged instead")] + public static event Action PluginsConfigChanged = () => { }; + + /// + /// Mica opacity changed event + /// + [Obsolete("Use IEventService with EventNames.MicaOpacityChanged instead")] + public static event Action MicaOpacityChanged = () => { }; + + /// + /// Develop settings changed event + /// + [Obsolete("Use IEventService with EventNames.DevelopSettingsChanged instead")] + public static event Action DevelopSettingsChanged = () => { }; + + /// + /// Log config updated event + /// + [Obsolete("Use IEventService with EventNames.LogConfigUpdated instead")] + public static event Action LogConfigUpdated = () => { }; + + /// + /// Theme config changed event + /// + [Obsolete("Use IEventService with EventNames.ThemeConfigChanged instead")] + public static event Action ThemeConfigChanged = () => { }; + + /// + /// Use statistics changed event + /// + [Obsolete("Use IEventService with EventNames.UseStatisticsChanged instead")] + public static event Action UseStatisticsChanged = () => { }; + + /// + /// Devices server port changed event + /// + [Obsolete("Use IEventService with EventNames.DevicesServerPortChanged instead")] + public static event Action DevicesServerPortChanged = port => { }; + + /// + /// Plugins server port changed event + /// + [Obsolete("Use IEventService with EventNames.PluginsServerPortChanged instead")] + public static event Action PluginsServerPortChanged = port => { }; + + /// + /// Activities updated event + /// + [Obsolete("Use IEventService with EventNames.OnActivitiesUpdated instead")] + public static event Action OnActivitiesUpdated = () => { }; + + /// + /// Receive cancel exchanging device key event + /// + [Obsolete("Use IEventService with EventNames.OnReceiveCancelExchangingDeviceKey instead")] + public static event Action OnReceiveCancelExchangingDeviceKey = () => { }; + + /// + /// Exiting event + /// + [Obsolete("Use IEventService with EventNames.OnExiting instead")] + public static event Action OnExiting = () => { }; + + /// + /// Receiving device info event + /// + [Obsolete("Use IEventService with EventNames.OnReceivingDeviceInfo instead")] + public static event Action OnReceivingDeviceInfo = _ => { }; + + /// + /// Config hot reloaded event + /// + [Obsolete("Use IEventService with EventNames.OnConfigHotReloaded instead")] + public static event Action OnConfigHotReloaded = () => { }; + + /// + /// Accepting device key event + /// + [Obsolete("Use IEventService with EventNames.OnAcceptingDeviceKey instead")] + public static event Action OnAcceptingDeviceKey = _ => { }; + +#pragma warning restore CS0067 // Event is never used + + #endregion +} diff --git a/KitX Clients/KitX Core/KitX.Core/FileWatcher/FileWatcherManager.cs b/KitX Clients/KitX Core/KitX.Core/FileWatcher/FileWatcherManager.cs new file mode 100644 index 00000000..1b3e50bf --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/FileWatcher/FileWatcherManager.cs @@ -0,0 +1,207 @@ +using System; +using System.Collections.Generic; +using System.IO; +using KitX.Core.Contract.FileWatcher; + +namespace KitX.Core.FileWatcher; + +/// +/// File watcher manager for monitoring file changes +/// +public class FileWatcherManager : IFileWatcherService +{ + private static FileWatcherManager? _instance; + + /// + /// Gets the singleton instance + /// + internal static FileWatcherManager Instance => _instance ??= new(); + + private readonly Dictionary _watchers = new(); + + /// + /// Private constructor + /// + private FileWatcherManager() { } + + /// + /// Registers a file watcher + /// + /// The file path to watch + /// The callback when file changes + public void RegisterWatcher(string filePath, FileSystemEventHandler onChanged) + { + RegisterWatcher(Guid.NewGuid().ToString(), filePath, onChanged); + } + + /// + /// Registers a file watcher with a specific name + /// + /// The watcher name + /// The file path to watch + /// The callback when file changes + public void RegisterWatcher(string name, string filePath, FileSystemEventHandler onChanged) + { + if (!_watchers.ContainsKey(name)) + { + var watcher = new FileWatcher(filePath, onChanged); + _watchers.Add(name, watcher); + } + else + { + throw new InvalidOperationException($"FileWatcher {name} already exists."); + } + } + + /// + /// Unregisters a file watcher by file path + /// + /// The file path to stop watching + public void UnregisterWatcher(string filePath) + { + // Find and remove watcher by path + var keyToRemove = default(string); + foreach (var kvp in _watchers) + { + if (kvp.Value.FilePath == filePath) + { + keyToRemove = kvp.Key; + break; + } + } + + if (keyToRemove != null) + { + _watchers[keyToRemove]?.Dispose(); + _watchers.Remove(keyToRemove); + } + } + + /// + /// Unregisters a file watcher by name + /// + /// The watcher name + public void UnregisterWatcherByName(string name) + { + if (_watchers.TryGetValue(name, out var watcher)) + { + watcher?.Dispose(); + _watchers.Remove(name); + } + } + + /// + /// Increases the exception count for a watcher + /// + /// The watcher name + /// The count to increase + public void IncreaseExceptCount(string name, int count = 1) + { + if (_watchers.TryGetValue(name, out var watcher)) + { + watcher?.IncreaseExceptCount(count); + } + } + + /// + /// Decreases the exception count for a watcher + /// + /// The watcher name + /// The count to decrease + public void DecreaseExceptCount(string name, int count = 1) + { + if (_watchers.TryGetValue(name, out var watcher)) + { + watcher?.DecreaseExceptCount(count); + } + } + + /// + /// Clears all watchers + /// + public void Clear() + { + foreach (var watcher in _watchers.Values) + { + watcher?.Dispose(); + } + + _watchers.Clear(); + } +} + +/// +/// Internal file watcher implementation +/// +internal class FileWatcher : IDisposable +{ + private int _exceptCounts = 0; + private FileSystemWatcher? _watcher = null; + + /// + /// Gets the file path being watched + /// + public string? FilePath { get; private set; } + + /// + /// Creates a new file watcher + /// + /// The file path to watch + /// The callback when file changes + /// The notify filters + public FileWatcher( + string filePath, + FileSystemEventHandler onChanged, + NotifyFilters? notifyFilters = null + ) + { + FilePath = filePath; + + var filepath = Path.GetFullPath(filePath); + + var path = Path.GetDirectoryName(filepath) + ?? throw new NullReferenceException($"Failed in {nameof(Path.GetDirectoryName)}"); + + _watcher = new FileSystemWatcher + { + NotifyFilter = notifyFilters ?? NotifyFilters.LastWrite, + Path = path, + Filter = Path.GetFileName(filepath) + }; + + _watcher.Changed += (x, y) => + { + if (_exceptCounts > 0) + { + --_exceptCounts; + } + else + { + onChanged(x, y); + } + }; + + _watcher.EnableRaisingEvents = true; + } + + /// + /// Increases the exception count + /// + /// The count to increase + public void IncreaseExceptCount(int count) => _exceptCounts += count; + + /// + /// Decreases the exception count + /// + /// The count to decrease + public void DecreaseExceptCount(int count) => _exceptCounts -= count; + + /// + /// Disposes the file watcher + /// + public void Dispose() + { + _watcher?.Dispose(); + _watcher = null; + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Hotkey/KeyHookManager.cs b/KitX Clients/KitX Core/KitX.Core/Hotkey/KeyHookManager.cs new file mode 100644 index 00000000..86d66b11 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Hotkey/KeyHookManager.cs @@ -0,0 +1,143 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using KitX.Core.Contract.Hotkey; +using SharpHook; +using SharpHook.Data; + +namespace KitX.Core.Hotkey; + +/// +/// Key hook manager for global hotkeys +/// +public class KeyHookManager : IKeyHookService +{ + private static KeyHookManager? _instance; + + /// + /// Gets the singleton instance + /// + internal static KeyHookManager Instance => _instance ??= new(); + + private const int KeysLimitation = 5; + + private readonly Queue _keyPressed = new(); + + private readonly Dictionary _hotKeyHandlers = new(); + + private readonly Dictionary> _hotKeyHandlersWithParams = new(); + + private TaskPoolGlobalHook? _hook; + + /// + /// Private constructor + /// + private KeyHookManager() { } + + /// + /// Starts the key hook + /// + public void StartHook() + { + if (_hook != null) + return; + + _hook = new TaskPoolGlobalHook(); + + _hook.KeyPressed += OnKeyPressed; + + _hook.RunAsync(); + } + + /// + /// Stops the key hook + /// + public void StopHook() + { + if (_hook == null) + return; + + _hook.KeyPressed -= OnKeyPressed; + + _hook.Dispose(); + + _hook = null; + } + + /// + /// Registers a hotkey handler + /// + /// The keys sequence + /// The handler + public void RegisterHotKeyHandler(string keysSequence, Action handler) + { + _hotKeyHandlers[keysSequence] = handler; + } + + /// + /// Registers a hotkey handler with key codes parameter + /// + /// The keys sequence + /// The handler that receives key codes + public void RegisterHotKeyHandler(string keysSequence, Action handler) + { + _hotKeyHandlersWithParams[keysSequence] = handler; + } + + /// + /// Unregisters a hotkey handler + /// + /// The keys sequence + public void UnregisterHotKeyHandler(string keysSequence) + { + if (_hotKeyHandlers.ContainsKey(keysSequence)) + { + _hotKeyHandlers.Remove(keysSequence); + } + } + + private void OnKeyPressed(object? sender, KeyboardHookEventArgs args) + { + _keyPressed.Enqueue(args.Data.KeyCode); + + if (_keyPressed.Count > KeysLimitation) + { + _keyPressed.Dequeue(); + } + + VerifyKeys(); + } + + private void VerifyKeys() + { + var index = 0; + + var tmpList = new KeyCode[KeysLimitation]; + + foreach (var key in _keyPressed) + { + tmpList[index] = key; + ++index; + } + + var keysSequence = KeysToString(tmpList); + + if (_hotKeyHandlers.TryGetValue(keysSequence, out var handler)) + { + handler?.Invoke(); + } + + // Also call handlers with string[] parameter + if (_hotKeyHandlersWithParams.TryGetValue(keysSequence, out var handlerWithParams)) + { + var keyStrings = tmpList.Select(k => k.ToString()).ToArray(); + handlerWithParams?.Invoke(keyStrings); + } + } + + private string KeysToString(KeyCode[] keys) + { + return string.Join("+", keys); + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/KitX.Core.csproj b/KitX Clients/KitX Core/KitX.Core/KitX.Core.csproj new file mode 100644 index 00000000..96ee7c02 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/KitX.Core.csproj @@ -0,0 +1,56 @@ + + + + net10.0 + enable + enable + KitX.Core + + + + $(Version) + $(Version) + 24.10.$([System.DateTime]::UtcNow.Date.Subtract($([System.DateTime]::Parse("2024-02-07"))).TotalDays).$([System.Math]::Floor($([System.DateTime]::UtcNow.TimeOfDay.TotalMinutes))) + + + + + + <_Parameter1>KitX.Core.DI.Tests + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/KitX Clients/KitX Core/KitX.Core/Plugin/PluginConnector.cs b/KitX Clients/KitX Core/KitX.Core/Plugin/PluginConnector.cs new file mode 100644 index 00000000..b0b91f68 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Plugin/PluginConnector.cs @@ -0,0 +1,308 @@ +using System; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Fleck; +using KitX.Core.Device; +using KitX.Shared.CSharp.Plugin; +using KitX.Shared.CSharp.WebCommand; +using KitX.Shared.CSharp.WebCommand.Details; +using KitX.Shared.CSharp.WebCommand.Infos; +using Serilog; +using CTask = System.Threading.Tasks.Task; + +namespace KitX.Core.Plugin; + +/// +/// Plugin connector for managing plugin WebSocket connections +/// Phase 5: Decoupled from UI ViewInstances, uses events instead +/// +public class PluginConnector +{ + private readonly IWebSocketConnection? _connection; + private readonly IWebSocketConnectionInfo? _connectionInfo; + private string? _path; + private bool _initialized = false; + private PluginInfo? _pluginInfo; + private ServerStatus _connectorStatus = ServerStatus.Pending; + private readonly JsonSerializerOptions _serializerOptions = new() + { + WriteIndented = true, + IncludeFields = true, + PropertyNameCaseInsensitive = true, + }; + + /// + /// Plugin status updated event + /// + public event Action? PluginStatusUpdated; + + /// + /// Plugin response event - when receiving plugin function call responses + /// + public static event Action? OnPluginResponse; + + /// + /// Event raised when a plugin is registered + /// UI layer should subscribe to this to update ViewInstances.PluginInfos + /// + public event EventHandler? PluginRegistered; + + /// + /// Event raised when a plugin is unregistered/disconnected + /// UI layer should subscribe to this to update ViewInstances.PluginInfos + /// + public event EventHandler? PluginUnregistered; + + public PluginConnector() { } + + public PluginConnector(IWebSocketConnection socket) + { + _connection = socket; + _connectionInfo = socket.ConnectionInfo; + } + + /// + /// Gets or sets the plugin path + /// + public string? Path + { + get => _path; + set + { + _path = value; + PluginStatusUpdated?.Invoke(); + } + } + + /// + /// Gets the connection ID + /// + public string? ConnectionId => Path; + + /// + /// Gets or sets the plugin info + /// + public PluginInfo? PluginInfo + { + get => _pluginInfo; + set + { + _pluginInfo = value; + PluginStatusUpdated?.Invoke(); + } + } + + /// + /// Gets a value indicating whether plugin info is available + /// + public bool PluginInfoAvailable => PluginInfo is null; + + /// + /// Gets or sets the connector status + /// + public ServerStatus ConnectorStatus + { + get => _connectorStatus; + set + { + _connectorStatus = value; + PluginStatusUpdated?.Invoke(); + } + } + + /// + /// Initializes the connector + /// + public PluginConnector Initialize() + { + _initialized = true; + Path = _connectionInfo!.Path.Trim('/'); + return this; + } + + /// + /// Runs the connector + /// + public PluginConnector Run() + { + if (_initialized == false) + Initialize(); + + const string location = $"{nameof(PluginConnector)}.{nameof(Run)}"; + + _connection!.OnOpen = () => { }; + + _connection.OnClose = () => + { + try + { + if (PluginInfo is not null) + { + // Trigger event instead of directly removing from ViewInstances + PluginUnregistered?.Invoke(this, new PluginUnregisteredEventArgs + { + PluginInfo = PluginInfo + }); + } + } + catch (Exception e) + { + Log.Warning(e, $"In {location}: {e.Message}"); + } + + // TODO: Remove from PluginsServer.PluginConnectors + }; + + _connection.OnMessage = message => + { + var kwc = JsonSerializer.Deserialize(message, _serializerOptions); + + if (kwc is null) + return; + + var command = JsonSerializer.Deserialize(kwc.Content, _serializerOptions); + + // Command is a value type, so it can't be null, but we check deserialization success + if (command.Tags != null && + command.Tags.TryGetValue("RequestId", out var requestId)) + { + // Trigger plugin response event + OnPluginResponse?.Invoke(requestId, kwc.Content); + return; + } + + HandleCommand(command, kwc); + }; + + _connection.OnError = ex => + { + try + { + if (PluginInfo is not null) + { + // Trigger event instead of directly removing from ViewInstances + PluginUnregistered?.Invoke(this, new PluginUnregisteredEventArgs + { + PluginInfo = PluginInfo + }); + } + } + catch (Exception e) + { + Log.Warning(e, $"In {location}: {e.Message}"); + } + + // TODO: Remove from PluginsServer.PluginConnectors + + Log.Error(ex, $"In {location}: {ex.Message}"); + }; + + return this; + } + + /// + /// Handles incoming commands from the plugin + /// + private void HandleCommand(Command command, Request request) + { + const string location = $"{nameof(PluginConnector)}.{nameof(HandleCommand)}"; + + switch (command.Request) + { + case CommandRequestInfo.RegisterPlugin: + var body = Encoding.UTF8.GetString(command.Body.AsSpan(0, command.BodyLength).ToArray()); + PluginInfo = JsonSerializer.Deserialize(body, _serializerOptions); + + ArgumentNullException.ThrowIfNull(PluginInfo, nameof(PluginInfo)); + ArgumentNullException.ThrowIfNull(PluginInfo.Tags, nameof(PluginInfo.Tags)); + + PluginInfo.Tags.Add(nameof(ConnectionId), ConnectionId ?? string.Empty); + PluginInfo.Tags.Add("JoinTime", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss(FF)")); + + // Trigger event instead of directly adding to ViewInstances + PluginRegistered?.Invoke(this, new PluginRegisteredEventArgs + { + PluginInfo = PluginInfo + }); + + Log.Information($"In {location}: New plugin registered with {body.Replace("\r", "").Replace("\n", "")}"); + break; + + case CommandRequestInfo.RequestWorkingDetail: + SendWorkingDetail(); + break; + + case CommandRequestInfo.ReportStatus: + // TODO: Handle status report + break; + + case CommandRequestInfo.RequestCommand: + // TODO: Handle command request + break; + } + } + + /// + /// Sends a message + /// + private void SendMessage(T content) => _connection!.Send(JsonSerializer.Serialize(content, _serializerOptions)); + + /// + /// Sends working detail + /// + private void SendWorkingDetail() + { + if (_path is null or { Length: 0 }) + { + SendMessage(new PluginWorkingDetail + { + PluginDataDirectory = null, + PluginSaveDirectory = null + }); + } + } + + /// + /// Sends a request to the plugin + /// + public async void Request(Request request) + { + await _connection!.Send(JsonSerializer.Serialize(request, _serializerOptions)); + } + + /// + /// Closes the connector + /// + public async Task CloseAsync() + { + await CTask.Run(() => + { + _connection!.Close(); + }); + + return this; + } +} + +/// +/// Plugin registered event arguments +/// +public class PluginRegisteredEventArgs : EventArgs +{ + /// + /// Gets or sets the plugin info + /// + public PluginInfo? PluginInfo { get; set; } +} + +/// +/// Plugin unregistered event arguments +/// +public class PluginUnregisteredEventArgs : EventArgs +{ + /// + /// Gets or sets the plugin info + /// + public PluginInfo? PluginInfo { get; set; } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Plugin/PluginsManager.cs b/KitX Clients/KitX Core/KitX.Core/Plugin/PluginsManager.cs new file mode 100644 index 00000000..0ae48d53 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Plugin/PluginsManager.cs @@ -0,0 +1,369 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using KitX.Core.Contract.Configuration; +using KitX.Core.Contract.Plugin; +using KitX.Shared.CSharp.Device; +using KitX.Shared.CSharp.Loader; +using KitX.Shared.CSharp.Plugin; +using Serilog; + +namespace KitX.Core.Plugin; + +/// +/// Plugin manager for managing plugin installations and lifecycle +/// +public class PluginsManager : IPluginService +{ + private static PluginsManager? _instance; + + /// + /// Gets the singleton instance + /// + internal static PluginsManager Instance => _instance ??= new(); + + private readonly List _plugins = new(); + + /// + /// Event raised when plugin status changes + /// + public event EventHandler? PluginStatusChanged; + + /// + /// Private constructor + /// + private PluginsManager() { } + + /// + /// Gets all installed plugins (alias for GetInstalledPlugins) + /// + public IReadOnlyList Plugins => _plugins.ToList(); + + /// + /// Imports a plugin (synchronous version for backward compatibility) + /// + /// Path to the plugin file + /// True if import succeeded + public bool ImportPlugin(string kxpFilePath) + { + return ImportPluginAsync(kxpFilePath).GetAwaiter().GetResult(); + } + + /// + /// Imports plugins (synchronous version for backward compatibility) + /// + /// Paths to the plugin files + /// True if import succeeded + public bool ImportPlugin(IEnumerable kxpFilePaths) + { + var result = true; + foreach (var path in kxpFilePaths) + { + result &= ImportPlugin(path); + } + return result; + } + + /// + /// Gets all installed plugins + /// + /// List of plugin installations + public IReadOnlyList GetInstalledPlugins() + { + return _plugins.ToList(); + } + + /// + /// Gets a plugin by its ID + /// + /// The plugin ID + /// The plugin installation or null if not found + public IPluginInstallation? GetPlugin(Guid pluginId) + { + return _plugins.FirstOrDefault(p => GeneratePluginId(p.PluginInfo!) == pluginId); + } + + /// + /// Generates a plugin ID from plugin info + /// + /// The plugin info + /// The generated plugin ID + private Guid GeneratePluginId(PluginInfo pluginInfo) + { + // Generate deterministic GUID from: PublisherName_AuthorName_Name_Version + var input = $"{pluginInfo.PublisherName}_{pluginInfo.AuthorName}_{pluginInfo.Name}_{pluginInfo.Version}"; + + // Use MD5 hash to create a deterministic GUID + using var md5 = System.Security.Cryptography.MD5.Create(); + var hash = md5.ComputeHash(System.Text.Encoding.UTF8.GetBytes(input)); + + // Convert first 16 bytes to GUID + return new Guid(hash.Take(16).ToArray()); + } + + /// + /// Imports a plugin package (.kxp file) + /// + /// Path to the .kxp file + /// True if import was successful + public async Task ImportPluginAsync(string kxpFilePath) + { + const string location = $"{nameof(PluginsManager)}.{nameof(ImportPluginAsync)}"; + + try + { + if (!File.Exists(kxpFilePath)) + { + Log.Error($"Plugin file not found: {kxpFilePath}"); + return false; + } + + // TODO: Implement actual KXP file decoding + // For now, we'll create a placeholder installation + var pluginInfo = new PluginInfo + { + Name = Path.GetFileNameWithoutExtension(kxpFilePath), + Version = "1.0.0", + PublisherName = "Unknown", + AuthorName = "Unknown" + }; + + var installation = new PluginInstallation + { + InstallPath = Path.GetDirectoryName(kxpFilePath) ?? "./Plugins/", + PluginInfo = pluginInfo, + LoaderInfo = new LoaderInfo(), + InstalledDevices = new List() + }; + + var generatedId = GeneratePluginId(pluginInfo); + _plugins.Add(installation); + + Log.Information($"Imported plugin: {pluginInfo.Name} (v{pluginInfo.Version})"); + + // Raise plugin status changed event + PluginStatusChanged?.Invoke(this, new PluginStatusChangedEventArgs + { + PluginId = generatedId, + PluginName = pluginInfo.Name, + OldStatus = PluginStatus.Unknown, + NewStatus = PluginStatus.Installed + }); + + return await System.Threading.Tasks.Task.FromResult(true); + } + catch (Exception ex) + { + Log.Error(ex, $"In {location}: Error importing plugin {kxpFilePath}: {ex.Message}"); + return await System.Threading.Tasks.Task.FromResult(false); + } + } + + /// + /// Removes a plugin + /// + /// The plugin ID + /// True if removal was successful + public async Task RemovePluginAsync(Guid pluginId) + { + const string location = $"{nameof(PluginsManager)}.{nameof(RemovePluginAsync)}"; + + try + { + var plugin = _plugins.FirstOrDefault(p => GeneratePluginId(p.PluginInfo!) == pluginId); + + if (plugin == null) + { + Log.Warning($"Plugin not found: {pluginId}"); + return await System.Threading.Tasks.Task.FromResult(false); + } + + // Remove from list + _plugins.Remove(plugin); + + // TODO: Delete plugin files if needed + if (Directory.Exists(plugin.InstallPath)) + { + try + { + Directory.Delete(plugin.InstallPath, true); + } + catch (Exception ex) + { + Log.Warning(ex, $"Failed to delete plugin directory: {plugin.InstallPath}"); + } + } + + Log.Information($"Removed plugin: {plugin.PluginInfo?.Name}"); + + // Raise plugin status changed event + PluginStatusChanged?.Invoke(this, new PluginStatusChangedEventArgs + { + PluginId = pluginId, + PluginName = plugin.PluginInfo?.Name ?? "Unknown", + OldStatus = PluginStatus.Installed, + NewStatus = PluginStatus.Stopped + }); + + return await System.Threading.Tasks.Task.FromResult(true); + } + catch (Exception ex) + { + Log.Error(ex, $"In {location}: Error removing plugin {pluginId}: {ex.Message}"); + return await System.Threading.Tasks.Task.FromResult(false); + } + } + + /// + /// Starts a plugin + /// + /// The plugin ID + /// True if start was successful + public async Task StartPluginAsync(Guid pluginId) + { + const string location = $"{nameof(PluginsManager)}.{nameof(StartPluginAsync)}"; + + try + { + var plugin = _plugins.FirstOrDefault(p => GeneratePluginId(p.PluginInfo!) == pluginId); + + if (plugin == null) + { + Log.Warning($"Plugin not found: {pluginId}"); + return await System.Threading.Tasks.Task.FromResult(false); + } + + // TODO: Implement actual plugin startup logic + // This would involve starting the loader process + + plugin.IsRunning = true; + + Log.Information($"Started plugin: {plugin.PluginInfo?.Name}"); + + // Raise plugin status changed event + PluginStatusChanged?.Invoke(this, new PluginStatusChangedEventArgs + { + PluginId = pluginId, + PluginName = plugin.PluginInfo?.Name ?? "Unknown", + OldStatus = PluginStatus.Installed, + NewStatus = PluginStatus.Running + }); + + return await System.Threading.Tasks.Task.FromResult(true); + } + catch (Exception ex) + { + Log.Error(ex, $"In {location}: Error starting plugin {pluginId}: {ex.Message}"); + return await System.Threading.Tasks.Task.FromResult(false); + } + } + + /// + /// Stops a plugin + /// + /// The plugin ID + /// True if stop was successful + public async Task StopPluginAsync(Guid pluginId) + { + const string location = $"{nameof(PluginsManager)}.{nameof(StopPluginAsync)}"; + + try + { + var plugin = _plugins.FirstOrDefault(p => GeneratePluginId(p.PluginInfo!) == pluginId); + + if (plugin == null) + { + Log.Warning($"Plugin not found: {pluginId}"); + return await System.Threading.Tasks.Task.FromResult(false); + } + + // TODO: Implement actual plugin shutdown logic + // This would involve stopping the loader process + + plugin.IsRunning = false; + + Log.Information($"Stopped plugin: {plugin.PluginInfo?.Name}"); + + // Raise plugin status changed event + PluginStatusChanged?.Invoke(this, new PluginStatusChangedEventArgs + { + PluginId = pluginId, + PluginName = plugin.PluginInfo?.Name ?? "Unknown", + OldStatus = PluginStatus.Running, + NewStatus = PluginStatus.Stopped + }); + + return await System.Threading.Tasks.Task.FromResult(true); + } + catch (Exception ex) + { + Log.Error(ex, $"In {location}: Error stopping plugin {pluginId}: {ex.Message}"); + return await System.Threading.Tasks.Task.FromResult(false); + } + } + + /// + /// Calls a plugin function + /// + /// The plugin ID + /// The function name + /// Optional parameters + /// The function result + public async Task CallPluginFunctionAsync(Guid pluginId, string functionName, Dictionary? parameters = null) + { + const string location = $"{nameof(PluginsManager)}.{nameof(CallPluginFunctionAsync)}"; + + try + { + var plugin = _plugins.FirstOrDefault(p => GeneratePluginId(p.PluginInfo!) == pluginId); + + if (plugin == null) + { + Log.Warning($"Plugin not found: {pluginId}"); + return await System.Threading.Tasks.Task.FromResult(null); + } + + if (!plugin.IsRunning) + { + Log.Warning($"Plugin is not running: {plugin.PluginInfo?.Name}"); + return await System.Threading.Tasks.Task.FromResult(null); + } + + // TODO: Implement actual plugin function call + // This would involve sending a request to the loader process + + Log.Information($"Called function {functionName} on plugin {plugin.PluginInfo?.Name}"); + + return await System.Threading.Tasks.Task.FromResult(null); + } + catch (Exception ex) + { + Log.Error(ex, $"In {location}: Error calling function {functionName} on plugin {pluginId}: {ex.Message}"); + return await System.Threading.Tasks.Task.FromResult(null); + } + } +} + +/// +/// Plugin installation implementation +/// +public class PluginInstallation : IPluginInstallation +{ + public string? InstallPath { get; set; } + public PluginInfo? PluginInfo { get; set; } + public LoaderInfo? LoaderInfo { get; set; } + + private List _installedDevices = new(); + + public IList InstalledDevices + { + get => _installedDevices; + set => _installedDevices = new List(value); + } + + public bool IsRunning { get; set; } +} diff --git a/KitX Clients/KitX Core/KitX.Core/README.md b/KitX Clients/KitX Core/KitX.Core/README.md new file mode 100644 index 00000000..e6da96be --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/README.md @@ -0,0 +1,97 @@ +# KitX.Core + +KitX Core 业务逻辑层 - 负责 KitX Client 的核心业务逻辑实现。 + +## 项目说明 + +本项目是 KitX Dashboard Core-UI 分离重构的一部分,负责实现核心业务逻辑,与 UI 层完全解耦。 + +## 架构设计 + +### 项目职责 + +- 实现配置管理 (Configuration) +- 实现插件管理 (Plugin Management) +- 实现设备管理 (Device Management) +- 实现安全管理 (Security) +- 实现活动记录 (Activity Logging) +- 实现统计分析 (Statistics) +- 实现工作流执行 (Workflow Execution) +- 实现事件系统 (Event System) +- 实现任务调度 (Task Scheduling) +- 实现文件监控 (File Watching) +- 实现全局热键 (Global Hotkeys) +- 实现公告服务 (Announcement Service) + +### 文件夹结构 + +``` +KitX.Core/ +├── Configuration/ # 配置管理实现 +├── Plugin/ # 插件管理实现 +├── Device/ # 设备管理实现 +├── Security/ # 安全管理实现 +├── Activity/ # 活动记录实现 +├── Statistics/ # 统计分析实现 +├── Workflow/ # 工作流执行实现 +├── Event/ # 事件系统实现 +├── Task/ # 任务调度实现 +├── FileWatcher/ # 文件监控实现 +├── Hotkey/ # 全局热键实现 +├── Announcement/ # 公告服务实现 +└── DI/ # 依赖注入配置 +``` + +## 依赖关系 + +### 项目引用 + +- `KitX.Core.Contract` - Core 服务接口定义 +- `KitX.Shared.CSharp` - 共享数据模型 +- `KitX.Contract.CSharp` - 插件契约接口 + +### NuGet 包 + +- `Microsoft.Extensions.DependencyInjection` (10.0.0) - 依赖注入框架 + +## 设计原则 + +1. **接口隔离**: 所有服务通过 `KitX.Core.Contract` 中定义的接口暴露功能 +2. **依赖注入**: 使用 MS.DI 容器管理依赖关系 +3. **事件驱动**: 通过事件向 UI 层推送状态变化 +4. **无 UI 依赖**: Core 层不依赖任何 UI 框架或组件 +5. **进程内调用**: 与 UI 层在同一进程内,使用 C# 接口调用 + +## 使用示例 + +### 在 Dashboard 中使用 Core 服务 + +```csharp +// 1. 注册 Core 服务 (在 App.axaml.cs 中) +var services = new ServiceCollection(); +services.AddCoreServices(); + +// 2. 在 ViewModel 中注入服务 +public class MainWindowViewModel : ViewModelBase +{ + private readonly IConfigService _configService; + + public MainWindowViewModel(IConfigService configService) + { + _configService = configService; + } +} +``` + +## 后续计划 + +参见 [KitX-Dashboard-Core-UI分离重构计划书.md](../../../KitX-Dashboard-Core-UI分离重构计划书.md),当前已完成阶段3, 但是阶段2中发现有3个网络服务类与UI耦合过深,无法直接迁移,因此阶段2完成度为93%,阶段3完成度为100%。具体请参照文档[阶段2-完整总结报告.md](../../../KitX%20Clients/KitX%20Core/KitX.Core/阶段2-完整总结报告.md)和[阶段3-完成总结报告.md](../../../KitX%20Clients/KitX%20Core/KitX.Core/阶段3-完成总结报告.md)。 + +## 相关文档 + +- [重构计划书](../../../KitX-Dashboard-Core-UI分离重构计划书.md) +- [接口定义项目](../../../KitX%20Standard/KitX%20Core%20Contracts/KitX.Core.Contract/README.md) + +## 许可证 + +AGPL-3.0-only diff --git a/KitX Clients/KitX Core/KitX.Core/Security/SecurityManager.cs b/KitX Clients/KitX Core/KitX.Core/Security/SecurityManager.cs new file mode 100644 index 00000000..7b6c3f5b --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Security/SecurityManager.cs @@ -0,0 +1,352 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using KitX.Core.Contract.Security; +using KitX.Shared.CSharp.Device; +using KitX.Shared.CSharp.Security; +using Serilog; + +namespace KitX.Core.Security; + +/// +/// Security manager for encryption and device key management +/// +public class SecurityManager : ISecurityService +{ + private static SecurityManager? _instance; + + /// + /// Gets the singleton instance + /// + internal static SecurityManager Instance => _instance ??= new(); + + private RSA? _rsaInstance; + + private DeviceKey? _localDeviceKey; + + /// + /// Gets the local device key + /// + public DeviceKey? LocalDeviceKey + { + get => _localDeviceKey; + set => _localDeviceKey = value; + } + + /// + /// Private constructor + /// + private SecurityManager() + { + Initialize(); + } + + /// + /// Initializes the security manager + /// + private void Initialize() + { + // Create RSA instance + _rsaInstance = RSA.Create(2048); + + // Note: In a real implementation, you would load the device key from config + // For now, we'll create a new one if it doesn't exist + if (_localDeviceKey == null) + { + GenerateLocalDeviceKey(); + } + else + { + // Load existing key + if (_localDeviceKey.RsaPublicKeyPem != null && _localDeviceKey.RsaPrivateKeyPem != null) + { + _rsaInstance.ImportFromPem(_localDeviceKey.RsaPublicKeyPem); + _rsaInstance.ImportFromPem(_localDeviceKey.RsaPrivateKeyPem); + } + } + } + + private void GenerateLocalDeviceKey() + { + var device = new DeviceLocator + { + DeviceName = Environment.MachineName, + MacAddress = GetMacAddress() + }; + + _localDeviceKey = new DeviceKey + { + Device = device, + RsaPublicKeyPem = _rsaInstance!.ExportRSAPublicKeyPem(), + RsaPrivateKeyPem = _rsaInstance.ExportRSAPrivateKeyPem() + }; + } + + /// + /// Gets all device keys + /// + /// List of device keys + public IReadOnlyList GetDeviceKeys() + { + // This would typically come from SecurityConfig + return new List(); + } + + /// + /// Adds a device key + /// + /// The MAC address + /// The device name + /// The public key + /// True if successful + public bool AddDeviceKey(string macAddress, string deviceName, string publicKey) + { + try + { + var deviceKey = new DeviceKey + { + Device = new DeviceLocator + { + MacAddress = macAddress, + DeviceName = deviceName + }, + RsaPublicKeyPem = publicKey + }; + + // In a real implementation, you would save this to SecurityConfig + Log.Information($"Added device key for {deviceName} ({macAddress})"); + + return true; + } + catch (Exception ex) + { + Log.Error(ex, $"Error adding device key: {ex.Message}"); + return false; + } + } + + /// + /// Removes a device key + /// + /// The MAC address + /// True if successful + public bool RemoveDeviceKey(string macAddress) + { + try + { + // In a real implementation, you would remove this from SecurityConfig + Log.Information($"Removed device key for {macAddress}"); + return true; + } + catch (Exception ex) + { + Log.Error(ex, $"Error removing device key: {ex.Message}"); + return false; + } + } + + /// + /// Checks if a device is authorized + /// + /// The device locator + /// True if the device is authorized + public bool IsDeviceAuthorized(DeviceLocator device) + { + try + { + var deviceKeys = GetDeviceKeys(); + return deviceKeys.Any(x => IsSameDevice(x.MacAddress, device.MacAddress)); + } + catch (Exception ex) + { + Log.Error(ex, $"Error checking device authorization: {ex.Message}"); + return false; + } + } + + /// + /// Checks if two MAC addresses represent the same device + /// + private static bool IsSameDevice(string mac1, string mac2) + { + // Normalize MAC addresses for comparison + var normalized1 = mac1.Replace(":", "").Replace("-", "").ToUpperInvariant(); + var normalized2 = mac2.Replace(":", "").Replace("-", "").ToUpperInvariant(); + return normalized1 == normalized2; + } + + /// + /// Encrypts a string + /// + /// The content to encrypt + /// The target device MAC address + /// The encrypted string + public async Task EncryptStringAsync(string content, string targetDeviceMacAddress) + { + if (_rsaInstance == null) + { + throw new InvalidOperationException("RSA instance not initialized"); + } + + if (content.Length >= 90) + { + // TODO: Implement data splitting for longer content + Log.Warning("Data length is too long for RSA encryption"); + } + + try + { + var dataBytes = Encoding.UTF8.GetBytes(content); + var encrypted = _rsaInstance.Encrypt(dataBytes, RSAEncryptionPadding.OaepSHA256); + return Convert.ToBase64String(encrypted); + } + catch (Exception ex) + { + Log.Error(ex, $"Error encrypting string: {ex.Message}"); + throw; + } + } + + /// + /// Decrypts a string + /// + /// The encrypted content + /// The source device MAC address + /// The decrypted string + public async Task DecryptStringAsync(string encryptedContent, string sourceDeviceMacAddress) + { + if (_rsaInstance == null) + { + throw new InvalidOperationException("RSA instance not initialized"); + } + + if (encryptedContent.Length >= 90) + { + // TODO: Implement data splitting for longer content + Log.Warning("Data length is too long for RSA decryption"); + } + + try + { + var encryptedDataBytes = Convert.FromBase64String(encryptedContent); + var decrypted = _rsaInstance.Decrypt(encryptedDataBytes, RSAEncryptionPadding.OaepSHA256); + return Encoding.UTF8.GetString(decrypted); + } + catch (Exception ex) + { + Log.Error(ex, $"Error decrypting string: {ex.Message}"); + throw; + } + } + + /// + /// Computes the SHA1 hash of a string + /// + /// The content + /// The hash string + public string ComputeHash(string content) + { + var hash = SHA1.HashData(Encoding.UTF8.GetBytes(content)); + var sb = new StringBuilder(); + + foreach (var item in hash) + { + sb.Append(item.ToString("x2")); + } + + return sb.ToString(); + } + + /// + /// Encrypts a string with AES + /// + /// The source string + /// The encryption key + /// The encrypted string + public static string AesEncrypt(string source, string key) + { + var data = Encoding.UTF8.GetBytes(source); + var expandedKey = ExpandKey(key, 16); + var keyData = expandedKey; + var iv = expandedKey; + + using var aes = Aes.Create(); + aes.Key = keyData; + aes.IV = iv; + + var result = aes.EncryptCbc(data, iv, PaddingMode.ISO10126); + return Convert.ToBase64String(result); + } + + /// + /// Decrypts a string with AES + /// + /// The source string + /// The decryption key + /// Whether the source is in Base64 + /// The decrypted string + public static string AesDecrypt(string source, string key, bool isSourceInBase64 = true) + { + var data = isSourceInBase64 ? Convert.FromBase64String(source) : Encoding.UTF8.GetBytes(source); + var expandedKey = ExpandKey(key, 16); + var keyData = expandedKey; + var iv = expandedKey; + + using var aes = Aes.Create(); + aes.Key = keyData; + aes.IV = iv; + + var result = aes.DecryptCbc(data, iv, PaddingMode.ISO10126); + return Encoding.UTF8.GetString(result); + } + + private static byte[] ExpandKey(string key, int length) + { + var expandedKey = key.Length <= length ? key : key[..length]; + var expandIndex = 0; + + while (expandedKey.Length < length) + { + if (expandIndex == key.Length) + expandIndex = 0; + + expandedKey += key[expandIndex]; + expandIndex++; + } + + return Encoding.ASCII.GetBytes(expandedKey); + } + + private string GetMacAddress() + { + // Get the first MAC address from network interfaces + try + { + var nics = System.Net.NetworkInformation.NetworkInterface.GetAllNetworkInterfaces(); + foreach (var nic in nics) + { + if (nic.OperationalStatus == System.Net.NetworkInformation.OperationalStatus.Up) + { + return nic.GetPhysicalAddress().ToString(); + } + } + } + catch (Exception ex) + { + Log.Warning(ex, "Error getting MAC address"); + } + + return "Unknown"; + } + + /// + /// Disposes the security manager + /// + public void Dispose() + { + _rsaInstance?.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Statistics/StatisticsManager.cs b/KitX Clients/KitX Core/KitX.Core/Statistics/StatisticsManager.cs new file mode 100644 index 00000000..667d464a --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Statistics/StatisticsManager.cs @@ -0,0 +1,220 @@ +using System; +using System.Collections.Generic; +using System.IO; +using KitX.Core.Contract.Statistics; +using Serilog; +using STimer = System.Timers.Timer; + +namespace KitX.Core.Statistics; + +/// +/// Statistics manager for usage tracking +/// +public class StatisticsManager : IStatisticsService +{ + private static StatisticsManager? _instance; + + /// + /// Gets the singleton instance + /// + internal static StatisticsManager Instance => _instance ??= new(); + + /// + /// Gets use statistics (static property for backward compatibility) + /// + public static Dictionary? UseStatistics => Instance._useStatistics; + + private Dictionary? _useStatistics = []; + + private STimer? _timer; + + private bool _isRunning; + + /// + /// Private constructor + /// + private StatisticsManager() { } + + /// + /// Starts statistics collection + /// + public void Start() + { + if (_isRunning) + return; + + _isRunning = true; + + RecoverPreviousStatistics(); + + BeginRecord(); + } + + /// + /// Stops statistics collection + /// + public void Stop() + { + if (!_isRunning) + return; + + _isRunning = false; + + _timer?.Stop(); + _timer?.Dispose(); + _timer = null; + + SaveStatistics(); + } + + /// + /// Gets usage statistics + /// + /// Start date + /// End date + /// Usage statistics + public IUsageStatistics GetUsageStatistics(DateTime startDate, DateTime endDate) + { + var result = new UsageStatistics(); + + if (_useStatistics == null) + return result; + + foreach (var kvp in _useStatistics) + { + if (DateTime.TryParse(kvp.Key, out var date)) + { + if (date >= startDate && date <= endDate) + { + result.DailyUsage[date] = kvp.Value; + result.TotalUsageSeconds += kvp.Value; + } + } + } + + return result; + } + + private void RecoverPreviousStatistics() + { + const string location = $"{nameof(StatisticsManager)}.{nameof(RecoverPreviousStatistics)}"; + + try + { + var dataDir = GetUserDataDirectory(); + + if (!Directory.Exists(dataDir)) + Directory.CreateDirectory(dataDir); + + var useFile = "UseCount.json"; + var usePath = Path.Combine(dataDir, useFile); + + if (File.Exists(usePath)) + { + var useCountJson = File.ReadAllText(usePath); + _useStatistics = System.Text.Json.JsonSerializer.Deserialize>(useCountJson); + + if (_useStatistics != null) + { + var lastDT = DateTime.Parse(_useStatistics.Keys.Last()!); + var nowDate = DateTime.Now; + + while (!lastDT.ToString("MM.dd").Equals(nowDate.ToString("MM.dd"))) + { + lastDT = lastDT.AddDays(1); + _useStatistics[lastDT.ToString("MM.dd")] = 0; + } + } + } + else + { + _useStatistics = new Dictionary(); + var today = DateTime.Now.ToString("MM.dd"); + _useStatistics[today] = 0; + + SaveStatistics(); + } + } + catch (Exception e) + { + Log.Warning(e, $"In {location}: {e.Message}"); + _useStatistics = new Dictionary(); + } + } + + private void BeginRecord() + { + const string location = $"{nameof(StatisticsManager)}.{nameof(BeginRecord)}"; + + _timer = new STimer + { + Interval = 1000 * 60 * 0.6 // Update per 0.6 minutes + }; + + _timer.Elapsed += OnTimerElapsed; + _timer.Start(); + } + + private void OnTimerElapsed(object? sender, System.Timers.ElapsedEventArgs e) + { + const string location = $"{nameof(StatisticsManager)}.{nameof(OnTimerElapsed)}"; + + try + { + var today = DateTime.Now.ToString("MM.dd"); + + if (_useStatistics == null) + return; + + if (!_useStatistics.TryAdd(today, 0.01)) + { + _useStatistics[today] += 0.01; + _useStatistics[today] = Math.Round(_useStatistics[today], 2); + } + + SaveStatistics(); + } + catch (Exception ex) + { + Log.Error(ex, $"In {location}: {ex.Message}"); + } + } + + private void SaveStatistics() + { + const string location = $"{nameof(StatisticsManager)}.{nameof(SaveStatistics)}"; + + try + { + var dataDir = GetUserDataDirectory(); + + if (!Directory.Exists(dataDir)) + Directory.CreateDirectory(dataDir); + + var useFile = "UseCount.json"; + var usePath = Path.Combine(dataDir, useFile); + + var json = System.Text.Json.JsonSerializer.Serialize(_useStatistics); + File.WriteAllText(usePath, json); + } + catch (Exception ex) + { + Log.Warning(ex, $"In {location}: {ex.Message}"); + } + } + + private string GetUserDataDirectory() + { + // Use relative path "./Data/" to match legacy implementation + return "./Data/"; + } + + /// + /// Usage statistics implementation + /// + private class UsageStatistics : IUsageStatistics + { + public double TotalUsageSeconds { get; set; } + public Dictionary DailyUsage { get; } = new(); + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Task/TasksManager.cs b/KitX Clients/KitX Core/KitX.Core/Task/TasksManager.cs new file mode 100644 index 00000000..913b51e9 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Task/TasksManager.cs @@ -0,0 +1,127 @@ +using System; +using System.Threading.Tasks; +using KitX.Core.Contract.Tasks; +using Serilog; +using CTask = System.Threading.Tasks.Task; + +namespace KitX.Core.Task; + +/// +/// Tasks manager for background task management +/// +public class TasksManager : ITasksService +{ + private static TasksManager? _instance; + + /// + /// Gets the singleton instance + /// + internal static TasksManager Instance => _instance ??= new(); + + /// + /// Private constructor + /// + private TasksManager() { } + + /// + /// Runs a synchronous task + /// + /// The task to run + /// Optional task name + public void RunTask(Action task, string? taskName = null) + { + RunTask(task, taskName ?? nameof(Action), prompt: ">>> ", catchException: true, logIt: true); + } + + /// + /// Runs a synchronous task with detailed configuration + /// + /// The task to run + /// Task name + /// Log prompt prefix + /// Whether to catch exceptions + /// Whether to log the task + public static void RunTask( + Action task, + string name, + string prompt = ">>> ", + bool catchException = true, + bool logIt = true + ) + { + if (logIt) + Log.Information($"{prompt}Task `{name}` began."); + + if (catchException) + { + try + { + task(); + } + catch (Exception e) + { + if (logIt) + Log.Error(e, $"{prompt}Task `{name}` failed: {e.Message}"); + } + } + else + { + task(); + } + + if (logIt) + Log.Information($"{prompt}Task `{name}` done."); + } + + /// + /// Runs an asynchronous task + /// + /// The task to run + /// Optional task name + /// Task representing the async operation + public CTask RunTaskAsync(Func task, string? taskName = null) + { + return RunTaskAsync(task, taskName ?? nameof(Action), prompt: ">>> ", catchException: true, logIt: true); + } + + /// + /// Runs an asynchronous task with detailed configuration + /// + /// The task to run + /// Task name + /// Log prompt prefix + /// Whether to catch exceptions + /// Whether to log the task + /// Task representing the async operation + public async CTask RunTaskAsync( + Func task, + string name, + string prompt = ">>> ", + bool catchException = true, + bool logIt = true + ) + { + if (logIt) + Log.Information($"{prompt}Task `{name}` began."); + + if (catchException) + { + try + { + await CTask.Run(task); + } + catch (Exception e) + { + if (logIt) + Log.Error(e, $"{prompt}Task `{name}` failed: {e.Message}"); + } + } + else + { + await CTask.Run(task); + } + + if (logIt) + Log.Information($"{prompt}Task `{name}` done."); + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs new file mode 100644 index 00000000..3564fe31 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs @@ -0,0 +1,562 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Csharpell.Core; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Scripting; +using Kscript.CSharp.Parser; +using KitX.Core.Contract.Workflow; +using KitX.Shared.CSharp.Plugin; +using Serilog; +using CTask = System.Threading.Tasks.Task; + +namespace KitX.Core.Workflow; + +/// +/// Workflow script service for executing workflows +/// +public class WorkflowScriptService : IWorkflowService +{ + private static WorkflowScriptService? _instance; + + /// + /// Gets the singleton instance + /// + internal static WorkflowScriptService Instance => _instance ??= new(); + + private readonly List _workflows = new(); + + /// + /// CSharpScriptEngine instance for script execution + /// + private CSharpScriptEngine? _engine; + + /// + /// Whether the plugin manager is initialized + /// + private bool _isParserInitialized = false; + + /// + /// Stores available plugins for workflow execution + /// + private List _availablePlugins { get; set; } = new(); + + /// + /// Private constructor + /// + private WorkflowScriptService() { } + + /// + /// Gets the script engine, creating it if necessary + /// + private CSharpScriptEngine Engine => _engine ??= new CSharpScriptEngine(); + + /// + /// Gets the workflow list + /// + /// List of workflow cases + public IReadOnlyList GetWorkflows() + { + return _workflows.ToList(); + } + + /// + /// Runs a workflow + /// + /// The workflow ID + /// True if run was successful + public async Task RunWorkflowAsync(string workflowId) + { + const string location = $"{nameof(WorkflowScriptService)}.{nameof(RunWorkflowAsync)}"; + + try + { + var workflow = _workflows.FirstOrDefault(w => w.Id == workflowId); + + if (workflow == null) + { + Log.Warning($"Workflow not found: {workflowId}"); + return await System.Threading.Tasks.Task.FromResult(false); + } + + if (workflow.IsRunning) + { + Log.Warning($"Workflow is already running: {workflow.Name}"); + return await System.Threading.Tasks.Task.FromResult(false); + } + + workflow.IsRunning = true; + + // TODO: Implement actual workflow execution logic + // This would involve parsing the workflow script and executing it + + Log.Information($"Started workflow: {workflow.Name}"); + + return await System.Threading.Tasks.Task.FromResult(true); + } + catch (Exception ex) + { + Log.Error(ex, $"In {location}: Error running workflow {workflowId}: {ex.Message}"); + return await System.Threading.Tasks.Task.FromResult(false); + } + } + + /// + /// Stops a workflow + /// + /// The workflow ID + /// True if stop was successful + public async Task StopWorkflowAsync(string workflowId) + { + const string location = $"{nameof(WorkflowScriptService)}.{nameof(StopWorkflowAsync)}"; + + try + { + var workflow = _workflows.FirstOrDefault(w => w.Id == workflowId); + + if (workflow == null) + { + Log.Warning($"Workflow not found: {workflowId}"); + return await System.Threading.Tasks.Task.FromResult(false); + } + + if (!workflow.IsRunning) + { + Log.Warning($"Workflow is not running: {workflow.Name}"); + return await System.Threading.Tasks.Task.FromResult(false); + } + + workflow.IsRunning = false; + + // TODO: Implement actual workflow stopping logic + + Log.Information($"Stopped workflow: {workflow.Name}"); + + return await System.Threading.Tasks.Task.FromResult(true); + } + catch (Exception ex) + { + Log.Error(ex, $"In {location}: Error stopping workflow {workflowId}: {ex.Message}"); + return await System.Threading.Tasks.Task.FromResult(false); + } + } + + /// + /// Executes a workflow script + /// + /// The script content + /// Optional parameters + /// The execution result + public async Task ExecuteScriptAsync(string script, Dictionary? parameters = null) + { + const string location = $"{nameof(WorkflowScriptService)}.{nameof(ExecuteScriptAsync)}"; + + try + { + if (string.IsNullOrWhiteSpace(script)) + { + Log.Warning("Script is empty"); + return await System.Threading.Tasks.Task.FromResult(null); + } + + // Execute using the full ExecuteCodesAsync method + var result = await ExecuteCodesAsync(script, null, true, CancellationToken.None); + + return await System.Threading.Tasks.Task.FromResult(result); + } + catch (Exception ex) + { + Log.Error(ex, $"In {location}: Error executing script: {ex.Message}"); + return await System.Threading.Tasks.Task.FromResult(null); + } + } + + /// + /// Adds a workflow + /// + /// The workflow to add + public void AddWorkflow(IWorkflowCase workflow) + { + if (workflow != null && !_workflows.Any(w => w.Id == workflow.Id)) + { + _workflows.Add(workflow); + } + } + + /// + /// Removes a workflow + /// + /// The workflow ID + public void RemoveWorkflow(string workflowId) + { + var workflow = _workflows.FirstOrDefault(w => w.Id == workflowId); + if (workflow != null) + { + _workflows.Remove(workflow); + } + } + + /// + /// Executes workflow script codes with plugin dependencies + /// + public async Task ExecuteCodesAsync( + string code, + List? requiredPlugins = null, + bool includeTimestamp = true, + CancellationToken cancellationToken = default) + { + const string location = $"{nameof(WorkflowScriptService)}.{nameof(ExecuteCodesAsync)}"; + + var sw = new Stopwatch(); + var begin = DateTime.Now; + sw.Start(); + + try + { + // If plugins are required, ensure they are loaded + if (requiredPlugins != null && requiredPlugins.Any()) + { + // Ensure plugin manager is initialized + if (!_isParserInitialized) + { + InitializePluginManager(); + } + + // Ensure plugins are ready + var pluginsReady = await EnsurePluginsReadyAsync(requiredPlugins, cancellationToken); + + if (!pluginsReady) + { + Log.Warning("[WorkflowScriptService] Some plugins failed to start, but continuing script execution"); + } + + // Generate plugin API assembly + Assembly? pluginApiAssembly = null; + try + { + pluginApiAssembly = Parser.Generate(requiredPlugins, "KitXWorkflowPlugins"); + Log.Information($"[WorkflowScriptService] Successfully generated plugin API with {requiredPlugins.Count} plugins"); + } + catch (Exception ex) + { + var error = $"Failed to generate plugin API: {ex.Message}"; + Log.Error(error); + } + + // Execute script with plugins + var result = await ExecuteScriptWithPluginsAsync( + code, + pluginApiAssembly, + includeTimestamp, + cancellationToken + ); + + sw.Stop(); + + return includeTimestamp + ? new StringBuilder() + .AppendLine($"[{begin:yyyy-MM-dd HH:mm:ss}] [I] Workflow script posted.") + .AppendLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [I] Script ended, took {sw.ElapsedMilliseconds} ms.") + .AppendLine(result) + .ToString() + : result; + } + else + { + // No plugin dependencies, execute directly + return await ExecuteCodesWithoutPluginsAsync(code, includeTimestamp, cancellationToken); + } + } + catch (Exception ex) + { + sw.Stop(); + + Log.Error(ex, $"In {location}: Error executing code: {ex.Message}"); + + return includeTimestamp + ? new StringBuilder() + .AppendLine($"[{begin:yyyy-MM-dd HH:mm:ss}] [I] Workflow script posted.") + .AppendLine( + $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [E] Exception caught after {sw.ElapsedMilliseconds} ms, Message: {ex.Message}" + ) + .AppendLine(ex.StackTrace) + .ToString() + : ex.StackTrace; + } + } + + /// + /// Executes script with plugin dependencies + /// + private async Task ExecuteScriptWithPluginsAsync( + string code, + Assembly? pluginApiAssembly, + bool includeTimestamp, + CancellationToken cancellationToken) + { + try + { + var result = await Engine.ExecuteAsync( + code, + options => + { + options = options + .WithReferences(Assembly.GetExecutingAssembly()) + .WithImports( + "KitX", + "KitX.Core", + "KitX.Shared.CSharp.Plugin", + "System", + "System.Collections.Generic", + "System.Threading.Tasks" + ) + .WithLanguageVersion(LanguageVersion.Preview); + + // Add plugin API assembly if available + if (pluginApiAssembly != null) + { + options = options.WithReferences(pluginApiAssembly); + } + + return options; + }, + addDefaultImports: true, + runInReplMode: false, + cancellationToken: cancellationToken + ); + + return result?.ToString(); + } + catch (Exception ex) + { + Log.Error(ex, "[WorkflowScriptService] Error executing script with plugins"); + return $"Script execution error: {ex.Message}"; + } + } + + /// + /// Ensures required plugins are loaded and running + /// + private async Task EnsurePluginsReadyAsync( + List requiredPlugins, + CancellationToken cancellationToken = default) + { + // Note: This requires IPluginService to check if plugins are running + // For now, we log the required plugins + Log.Information($"[WorkflowScriptService] Checking {requiredPlugins.Count} required plugins"); + + foreach (var plugin in requiredPlugins) + { + Log.Information($"[WorkflowScriptService] Required plugin: {plugin.Name}"); + } + + // Simplified: assume all plugins are ready + // Full implementation would use IPluginService to check status + return await System.Threading.Tasks.Task.FromResult(true); + } + + /// + /// Executes code without plugin dependencies + /// + private async Task ExecuteCodesWithoutPluginsAsync( + string code, + bool includeTimestamp, + CancellationToken cancellationToken) + { + var sw = new Stopwatch(); + var begin = DateTime.Now; + sw.Start(); + + try + { + var result = await Engine.ExecuteAsync( + code, + options => options + .WithReferences(Assembly.GetExecutingAssembly()) + .WithImports("System", "System.Collections.Generic", "System.Threading.Tasks") + .WithLanguageVersion(LanguageVersion.Preview), + addDefaultImports: true, + runInReplMode: false, + cancellationToken: cancellationToken + ); + + sw.Stop(); + + return includeTimestamp + ? new StringBuilder() + .AppendLine($"[{begin:yyyy-MM-dd HH:mm:ss}] [I] Workflow script posted.") + .AppendLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [I] Script ended, took {sw.ElapsedMilliseconds} ms.") + .AppendLine(result?.ToString()) + .ToString() + : result?.ToString(); + } + catch (Exception ex) + { + sw.Stop(); + + return includeTimestamp + ? new StringBuilder() + .AppendLine($"[{begin:yyyy-MM-dd HH:mm:ss}] [I] Workflow script posted.") + .AppendLine( + $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [E] Exception caught after {sw.ElapsedMilliseconds} ms, Message: {ex.Message}" + ) + .AppendLine(ex.StackTrace) + .ToString() + : ex.StackTrace; + } + } + + /// + /// Initializes the plugin manager + /// + public void InitializePluginManager() + { + if (_isParserInitialized) return; + + try + { + // Create a basic plugin service provider + // Note: In full implementation, this would use the actual PluginsServer + var serviceProvider = new PluginServiceProvider(null); + + // Set up the parser with a mock plugin manager + Parser.SetPluginManager(new Kscript.CSharp.Parser.Core.MockPluginManager()); + + _isParserInitialized = true; + Log.Information("[WorkflowScriptService] Plugin manager initialized"); + } + catch (Exception ex) + { + Log.Error($"[WorkflowScriptService] Failed to initialize plugin manager: {ex.Message}"); + // Use mock manager as fallback + Parser.SetPluginManager(new Kscript.CSharp.Parser.Core.MockPluginManager()); + _isParserInitialized = true; + } + } + + /// + /// Updates the available plugins list + /// + public void UpdateAvailablePlugins(List plugins) + { + _availablePlugins = plugins ?? new List(); + Log.Information($"[WorkflowScriptService] Updated available plugins: {_availablePlugins.Count} plugins"); + } +} + +/// +/// Workflow case implementation +/// +public class WorkflowCase : IWorkflowCase +{ + public string Id { get; set; } = Guid.NewGuid().ToString(); + public string Name { get; set; } = "Untitled Workflow"; + public string Description { get; set; } = string.Empty; + public string IconPath { get; set; } = string.Empty; + public bool IsRunning { get; set; } + public string? ScriptPath { get; set; } +} + +/// +/// Plugin service provider implementation for workflow integration +/// +public class PluginServiceProvider : IPluginServiceProvider +{ + private readonly List _runningPlugins = new(); + private readonly object? _pluginsServer; + + /// + /// Constructor + /// + /// Plugins server instance (can be null) + public PluginServiceProvider(object? pluginsServer) + { + _pluginsServer = pluginsServer; + } + + /// + /// Generates a plugin ID from plugin info + /// + private Guid GeneratePluginId(PluginInfo pluginInfo) + { + // Generate deterministic GUID from: PublisherName_AuthorName_Name_Version + var input = $"{pluginInfo.PublisherName}_{pluginInfo.AuthorName}_{pluginInfo.Name}_{pluginInfo.Version}"; + + // Use MD5 hash to create a deterministic GUID + using var md5 = System.Security.Cryptography.MD5.Create(); + var hash = md5.ComputeHash(System.Text.Encoding.UTF8.GetBytes(input)); + + // Convert first 16 bytes to GUID + return new Guid(hash.Take(16).ToArray()); + } + + /// + /// Gets running plugins + /// + public IEnumerable GetRunningPlugins() + { + return _runningPlugins.ToList(); + } + + /// + /// Finds a plugin by name + /// + public PluginInfo? FindPlugin(string pluginName) + { + return _runningPlugins.FirstOrDefault(p => p.Name == pluginName); + } + + /// + /// Finds a connector for a plugin + /// + public object? FindConnector(PluginInfo pluginInfo) + { + // TODO: Implement connector lookup using plugins server + return null; + } + + /// + /// Sends a request asynchronously + /// + public CTask SendRequestAsync(object connector, object request) + { + // TODO: Implement request sending + return CTask.CompletedTask; + } + + /// + /// Subscribes to plugin responses + /// + public void SubscribeToResponses(Action responseHandler) + { + // TODO: Implement response subscription + } + + /// + /// Adds a running plugin + /// + public void AddRunningPlugin(PluginInfo pluginInfo) + { + if (!_runningPlugins.Any(p => GeneratePluginId(p) == GeneratePluginId(pluginInfo))) + { + _runningPlugins.Add(pluginInfo); + } + } + + /// + /// Removes a running plugin + /// + public void RemoveRunningPlugin(Guid pluginId) + { + var plugin = _runningPlugins.FirstOrDefault(p => GeneratePluginId(p) == pluginId); + if (plugin != null) + { + _runningPlugins.Remove(plugin); + } + } +} diff --git a/KitX Clients/KitX Dashboard b/KitX Clients/KitX Dashboard index c6b0c938..724cbc40 160000 --- a/KitX Clients/KitX Dashboard +++ b/KitX Clients/KitX Dashboard @@ -1 +1 @@ -Subproject commit c6b0c938540388443f9dcaa88d37cb09a50985f4 +Subproject commit 724cbc40150b4bef80f08ce601baf74b6e7838af diff --git a/KitX Standard b/KitX Standard index e80de9d7..2e24ebf6 160000 --- a/KitX Standard +++ b/KitX Standard @@ -1 +1 @@ -Subproject commit e80de9d7837165b2b00dd23bba9307fba979da75 +Subproject commit 2e24ebf68a89ea0e1a6186f983813c090eeae3a3 diff --git a/KitX.sln b/KitX.sln index 0010b027..4eca371f 100644 --- a/KitX.sln +++ b/KitX.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.2.32526.322 +# Visual Studio Version 18 +VisualStudioVersion = 18.1.11312.151 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{1BBE8A6D-740D-4252-ACF0-0AF4882DF8B5}" ProjectSection(SolutionItems) = preProject @@ -145,6 +145,16 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Kscript.CSharp.Compiler", " EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Kscript.CSharp.Parser.Examples", "KitX Standard\KitX Script\Kscript.CSharp.Parser.Examples\Kscript.CSharp.Parser.Examples.csproj", "{24D7FCC6-63D4-BACC-CADC-75A14B351A9F}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "KitX Core Contracts", "KitX Core Contracts", "{A3045119-5CE9-4A21-B0D1-E4B5B5310892}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KitX.Core.Contract", "KitX Standard\KitX Core Contracts\KitX.Core.Contract\KitX.Core.Contract.csproj", "{8BDC8F35-280A-49D6-5474-AD71A59A8405}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "KitX Core", "KitX Core", "{C4C2D1CC-294B-4C93-8A64-34D5212AD74E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KitX.Core", "KitX Clients\KitX Core\KitX.Core\KitX.Core.csproj", "{641562BA-D2B5-4799-8712-11C23912A79B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KitX.Core.DI.Tests", "KitX Clients\KitX Core\KitX.Core.DI.Tests\KitX.Core.DI.Tests.csproj", "{9D8CEB24-748C-0392-629C-45342937C32A}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -839,6 +849,66 @@ Global {24D7FCC6-63D4-BACC-CADC-75A14B351A9F}.Release|x64.Build.0 = Release|Any CPU {24D7FCC6-63D4-BACC-CADC-75A14B351A9F}.Release|x86.ActiveCfg = Release|Any CPU {24D7FCC6-63D4-BACC-CADC-75A14B351A9F}.Release|x86.Build.0 = Release|Any CPU + {8BDC8F35-280A-49D6-5474-AD71A59A8405}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8BDC8F35-280A-49D6-5474-AD71A59A8405}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8BDC8F35-280A-49D6-5474-AD71A59A8405}.Debug|ARM.ActiveCfg = Debug|Any CPU + {8BDC8F35-280A-49D6-5474-AD71A59A8405}.Debug|ARM.Build.0 = Debug|Any CPU + {8BDC8F35-280A-49D6-5474-AD71A59A8405}.Debug|ARM64.ActiveCfg = Debug|Any CPU + {8BDC8F35-280A-49D6-5474-AD71A59A8405}.Debug|ARM64.Build.0 = Debug|Any CPU + {8BDC8F35-280A-49D6-5474-AD71A59A8405}.Debug|x64.ActiveCfg = Debug|Any CPU + {8BDC8F35-280A-49D6-5474-AD71A59A8405}.Debug|x64.Build.0 = Debug|Any CPU + {8BDC8F35-280A-49D6-5474-AD71A59A8405}.Debug|x86.ActiveCfg = Debug|Any CPU + {8BDC8F35-280A-49D6-5474-AD71A59A8405}.Debug|x86.Build.0 = Debug|Any CPU + {8BDC8F35-280A-49D6-5474-AD71A59A8405}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8BDC8F35-280A-49D6-5474-AD71A59A8405}.Release|Any CPU.Build.0 = Release|Any CPU + {8BDC8F35-280A-49D6-5474-AD71A59A8405}.Release|ARM.ActiveCfg = Release|Any CPU + {8BDC8F35-280A-49D6-5474-AD71A59A8405}.Release|ARM.Build.0 = Release|Any CPU + {8BDC8F35-280A-49D6-5474-AD71A59A8405}.Release|ARM64.ActiveCfg = Release|Any CPU + {8BDC8F35-280A-49D6-5474-AD71A59A8405}.Release|ARM64.Build.0 = Release|Any CPU + {8BDC8F35-280A-49D6-5474-AD71A59A8405}.Release|x64.ActiveCfg = Release|Any CPU + {8BDC8F35-280A-49D6-5474-AD71A59A8405}.Release|x64.Build.0 = Release|Any CPU + {8BDC8F35-280A-49D6-5474-AD71A59A8405}.Release|x86.ActiveCfg = Release|Any CPU + {8BDC8F35-280A-49D6-5474-AD71A59A8405}.Release|x86.Build.0 = Release|Any CPU + {641562BA-D2B5-4799-8712-11C23912A79B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {641562BA-D2B5-4799-8712-11C23912A79B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {641562BA-D2B5-4799-8712-11C23912A79B}.Debug|ARM.ActiveCfg = Debug|Any CPU + {641562BA-D2B5-4799-8712-11C23912A79B}.Debug|ARM.Build.0 = Debug|Any CPU + {641562BA-D2B5-4799-8712-11C23912A79B}.Debug|ARM64.ActiveCfg = Debug|Any CPU + {641562BA-D2B5-4799-8712-11C23912A79B}.Debug|ARM64.Build.0 = Debug|Any CPU + {641562BA-D2B5-4799-8712-11C23912A79B}.Debug|x64.ActiveCfg = Debug|Any CPU + {641562BA-D2B5-4799-8712-11C23912A79B}.Debug|x64.Build.0 = Debug|Any CPU + {641562BA-D2B5-4799-8712-11C23912A79B}.Debug|x86.ActiveCfg = Debug|Any CPU + {641562BA-D2B5-4799-8712-11C23912A79B}.Debug|x86.Build.0 = Debug|Any CPU + {641562BA-D2B5-4799-8712-11C23912A79B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {641562BA-D2B5-4799-8712-11C23912A79B}.Release|Any CPU.Build.0 = Release|Any CPU + {641562BA-D2B5-4799-8712-11C23912A79B}.Release|ARM.ActiveCfg = Release|Any CPU + {641562BA-D2B5-4799-8712-11C23912A79B}.Release|ARM.Build.0 = Release|Any CPU + {641562BA-D2B5-4799-8712-11C23912A79B}.Release|ARM64.ActiveCfg = Release|Any CPU + {641562BA-D2B5-4799-8712-11C23912A79B}.Release|ARM64.Build.0 = Release|Any CPU + {641562BA-D2B5-4799-8712-11C23912A79B}.Release|x64.ActiveCfg = Release|Any CPU + {641562BA-D2B5-4799-8712-11C23912A79B}.Release|x64.Build.0 = Release|Any CPU + {641562BA-D2B5-4799-8712-11C23912A79B}.Release|x86.ActiveCfg = Release|Any CPU + {641562BA-D2B5-4799-8712-11C23912A79B}.Release|x86.Build.0 = Release|Any CPU + {9D8CEB24-748C-0392-629C-45342937C32A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9D8CEB24-748C-0392-629C-45342937C32A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9D8CEB24-748C-0392-629C-45342937C32A}.Debug|ARM.ActiveCfg = Debug|Any CPU + {9D8CEB24-748C-0392-629C-45342937C32A}.Debug|ARM.Build.0 = Debug|Any CPU + {9D8CEB24-748C-0392-629C-45342937C32A}.Debug|ARM64.ActiveCfg = Debug|Any CPU + {9D8CEB24-748C-0392-629C-45342937C32A}.Debug|ARM64.Build.0 = Debug|Any CPU + {9D8CEB24-748C-0392-629C-45342937C32A}.Debug|x64.ActiveCfg = Debug|Any CPU + {9D8CEB24-748C-0392-629C-45342937C32A}.Debug|x64.Build.0 = Debug|Any CPU + {9D8CEB24-748C-0392-629C-45342937C32A}.Debug|x86.ActiveCfg = Debug|Any CPU + {9D8CEB24-748C-0392-629C-45342937C32A}.Debug|x86.Build.0 = Debug|Any CPU + {9D8CEB24-748C-0392-629C-45342937C32A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9D8CEB24-748C-0392-629C-45342937C32A}.Release|Any CPU.Build.0 = Release|Any CPU + {9D8CEB24-748C-0392-629C-45342937C32A}.Release|ARM.ActiveCfg = Release|Any CPU + {9D8CEB24-748C-0392-629C-45342937C32A}.Release|ARM.Build.0 = Release|Any CPU + {9D8CEB24-748C-0392-629C-45342937C32A}.Release|ARM64.ActiveCfg = Release|Any CPU + {9D8CEB24-748C-0392-629C-45342937C32A}.Release|ARM64.Build.0 = Release|Any CPU + {9D8CEB24-748C-0392-629C-45342937C32A}.Release|x64.ActiveCfg = Release|Any CPU + {9D8CEB24-748C-0392-629C-45342937C32A}.Release|x64.Build.0 = Release|Any CPU + {9D8CEB24-748C-0392-629C-45342937C32A}.Release|x86.ActiveCfg = Release|Any CPU + {9D8CEB24-748C-0392-629C-45342937C32A}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -887,6 +957,11 @@ Global {91F13EBC-53CE-4032-AAFC-268E90C32E5F} = {B2234097-D037-4126-9CC0-F7ECF3FEEEA5} {1B22641F-6CC9-4F00-BDBE-07EA81E5CF8F} = {7C64F32B-E1EE-45F7-94A7-DA2AD9288249} {0EE16A23-B6A2-0A99-D990-1CA6386B2351} = {7C64F32B-E1EE-45F7-94A7-DA2AD9288249} + {A3045119-5CE9-4A21-B0D1-E4B5B5310892} = {37E33B90-88B7-4404-9D42-D672A2916E6C} + {8BDC8F35-280A-49D6-5474-AD71A59A8405} = {A3045119-5CE9-4A21-B0D1-E4B5B5310892} + {C4C2D1CC-294B-4C93-8A64-34D5212AD74E} = {673CF32C-65BF-4EB3-83D3-47FEC77B47A0} + {641562BA-D2B5-4799-8712-11C23912A79B} = {C4C2D1CC-294B-4C93-8A64-34D5212AD74E} + {9D8CEB24-748C-0392-629C-45342937C32A} = {C4C2D1CC-294B-4C93-8A64-34D5212AD74E} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {09BBC227-F41B-4D10-9E38-0EEE07ED17BC} From b222914524fd217597df4171905cbb199d6800c8 Mon Sep 17 00:00:00 2001 From: StarInk Date: Fri, 20 Feb 2026 20:18:04 +0100 Subject: [PATCH 012/110] =?UTF-8?q?=F0=9F=93=9D=20Chore:=20Update=20subpro?= =?UTF-8?q?ject=20commit=20for=20KitX=20Standard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- KitX Standard | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/KitX Standard b/KitX Standard index 2e24ebf6..4c0b44d2 160000 --- a/KitX Standard +++ b/KitX Standard @@ -1 +1 @@ -Subproject commit 2e24ebf68a89ea0e1a6186f983813c090eeae3a3 +Subproject commit 4c0b44d2d80d6ace5f3b047bc299a6ab50ec4bd2 From 6c713516be94db978ef93bae172c2c2109cbe3aa Mon Sep 17 00:00:00 2001 From: StarInk Date: Sat, 21 Feb 2026 03:52:26 +0100 Subject: [PATCH 013/110] =?UTF-8?q?=F0=9F=93=9D=20Chore:=20Remove=20local?= =?UTF-8?q?=20settings=20file=20and=20update=20.gitignore=20to=20exclude?= =?UTF-8?q?=20Claude=20Code=20directory?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 19 ------------------- .gitignore | 3 +++ 2 files changed, 3 insertions(+), 19 deletions(-) delete mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index b772a9c2..00000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "permissions": { - "allow": [ - "Edit", - "Bash(cd:*)", - "Write", - "Bash(dir:*)", - "Bash(git:*)", - "Bash(find:*)", - "Bash(del:*)", - "Bash(dotnet build:*)" - ] - }, - "enabledPlugins": { - "csharp-lsp@claude-plugins-official": true, - "code-review@claude-plugins-official": true, - "ralph-loop@claude-plugins-official": true - } -} diff --git a/.gitignore b/.gitignore index 007d66b5..0540355a 100644 --- a/.gitignore +++ b/.gitignore @@ -362,3 +362,6 @@ MigrationBackup/ # Ionide (cross platform F# VS Code tools) working folder .ionide/ + +# Claude Code +.claude/ From a448f65697f7fdff9487483f7183329d03a96eba Mon Sep 17 00:00:00 2001 From: StarInk Date: Sat, 21 Feb 2026 04:47:33 +0100 Subject: [PATCH 014/110] =?UTF-8?q?=F0=9F=93=9D=20Chore:=20Update=20subpro?= =?UTF-8?q?ject=20commit=20for=20KitX=20Dashboard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- KitX Clients/KitX Dashboard | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/KitX Clients/KitX Dashboard b/KitX Clients/KitX Dashboard index 724cbc40..73e6ab99 160000 --- a/KitX Clients/KitX Dashboard +++ b/KitX Clients/KitX Dashboard @@ -1 +1 @@ -Subproject commit 724cbc40150b4bef80f08ce601baf74b6e7838af +Subproject commit 73e6ab996e26648d99aeaebad22aaeeb1456cae7 From 9404fdf14439143b3122e19fd8c0ba1aefb39eb1 Mon Sep 17 00:00:00 2001 From: StarInk Date: Wed, 25 Feb 2026 03:47:20 +0100 Subject: [PATCH 015/110] =?UTF-8?q?=F0=9F=92=BE=20=F0=9F=90=9B=20Fix,=20Re?= =?UTF-8?q?factor(Core):=20=E4=BF=AE=E5=A4=8D=20EventService=20=E6=A0=88?= =?UTF-8?q?=E6=BA=A2=E5=87=BA=E5=B9=B6=E5=A2=9E=E5=BC=BA=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E6=96=AD=E5=BC=80=E4=BA=8B=E4=BB=B6=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复 Publish 泛型方法自我递归导致的 StackOverflowException - 添加 ThreadLocal 递归深度检测,防止无限递归 - 添加 lock 语句确保 Subscribe/Unsubscribe 线程安全 - 将 ConstantTable.DevicesServerPort 和 PluginsServerPort 改为 public - 修复 Fleck OnError 不触发 Closed 事件的问题,增强插件意外断开的处理 - 新增 PluginEventArgs, PluginConnectionEventArgs 事件参数类 - 新增 PluginDisconnected, PluginRegistered 等 EventNames 常量 - 创建 RealPluginManager 和 WorkflowOutput 支持工作流脚本调用插件方法 --- .gitignore | 3 + .../KitX Core/KitX.Core/ConstantTable.cs | 12 +- .../Device/DevicesDiscoveryServer.cs | 2 +- .../KitX.Core/Device/DevicesServer.cs | 12 + .../KitX.Core/Device/PluginsServer.cs | 202 +++++++---- .../KitX Core/KitX.Core/Event/EventArgs.cs | 28 ++ .../KitX Core/KitX.Core/Event/EventNames.cs | 20 ++ .../KitX Core/KitX.Core/Event/EventService.cs | 82 +++-- .../KitX Core/KitX.Core/KitX.Core.csproj | 1 + .../KitX.Core/Plugin/PluginsManager.cs | 313 ++++++++++++++++-- .../KitX.Core/Workflow/RealPluginManager.cs | 271 +++++++++++++++ .../KitX.Core/Workflow/WorkflowOutput.cs | 48 +++ .../Workflow/WorkflowScriptService.cs | 59 +++- KitX Clients/KitX Dashboard | 2 +- KitX SDK | 2 +- KitX Standard | 2 +- 16 files changed, 936 insertions(+), 123 deletions(-) create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/RealPluginManager.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowOutput.cs diff --git a/.gitignore b/.gitignore index 0540355a..dc32f326 100644 --- a/.gitignore +++ b/.gitignore @@ -365,3 +365,6 @@ MigrationBackup/ # Claude Code .claude/ + +# Package folder +Package/* diff --git a/KitX Clients/KitX Core/KitX.Core/ConstantTable.cs b/KitX Clients/KitX Core/KitX.Core/ConstantTable.cs index 15d775d1..0f9cde1c 100644 --- a/KitX Clients/KitX Core/KitX.Core/ConstantTable.cs +++ b/KitX Clients/KitX Core/KitX.Core/ConstantTable.cs @@ -3,7 +3,7 @@ namespace KitX.Core; -internal static class ConstantTable +public static class ConstantTable { internal const string AppName = "KitX"; @@ -31,9 +31,15 @@ internal static class ConstantTable internal static string? ExchangeDeviceKeyCode; - internal static int DevicesServerPort = -1; + /// + /// Devices Server Port - public for cross-assembly access + /// + public static int DevicesServerPort = -1; - internal static int PluginsServerPort = -1; + /// + /// Plugins Server Port - public for cross-assembly access + /// + public static int PluginsServerPort = -1; internal static bool Running = true; diff --git a/KitX Clients/KitX Core/KitX.Core/Device/DevicesDiscoveryServer.cs b/KitX Clients/KitX Core/KitX.Core/Device/DevicesDiscoveryServer.cs index fac8300f..51a78f24 100644 --- a/KitX Clients/KitX Core/KitX.Core/Device/DevicesDiscoveryServer.cs +++ b/KitX Clients/KitX Core/KitX.Core/Device/DevicesDiscoveryServer.cs @@ -243,7 +243,7 @@ private void UpdateDefaultDeviceInfo() DefaultDeviceInfo.Device.ResetIPv4(NetworkHelper.GetInterNetworkIPv4()) .ResetIPv6(NetworkHelper.GetInterNetworkIPv6()); DefaultDeviceInfo.PluginsServerPort = ConstantTable.PluginsServerPort; - DefaultDeviceInfo.PluginsCount = 0; // TODO: Will be updated from PluginsManager + DefaultDeviceInfo.PluginsCount = PluginsServer.Instance?.Connections?.Count ?? 0; DefaultDeviceInfo.IsMainDevice = ConstantTable.IsMainMachine; DefaultDeviceInfo.DevicesServerPort = ConstantTable.DevicesServerPort; DefaultDeviceInfo.DevicesServerBuildTime = ConstantTable.ServerBuildTime; diff --git a/KitX Clients/KitX Core/KitX.Core/Device/DevicesServer.cs b/KitX Clients/KitX Core/KitX.Core/Device/DevicesServer.cs index 42630701..8051bc33 100644 --- a/KitX Clients/KitX Core/KitX.Core/Device/DevicesServer.cs +++ b/KitX Clients/KitX Core/KitX.Core/Device/DevicesServer.cs @@ -4,6 +4,7 @@ using System.Threading; using System.Threading.Tasks; using KitX.Core.Contract.Device; +using KitX.Core.Event; using KitX.Shared.CSharp.Device; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; @@ -38,6 +39,11 @@ public class DevicesServer : IDeviceServer /// public ServerStatus Status => _status; + /// + /// Event raised when port changes + /// + public event EventHandler? PortChanged; + /// /// Gets or sets the port /// @@ -116,6 +122,12 @@ public IDeviceServer Run() var uri = new Uri(addresses.First()); Port = uri.Port; + // Update ConstantTable with the actual port + ConstantTable.DevicesServerPort = Port ?? 0; + + // Publish port changed event via EventService only (removed direct PortChanged event to avoid potential recursion) + EventService.Instance.Publish(EventNames.DevicesServerPortChanged, new PortChangedEventArgs { Port = Port ?? 0 }); + Log.Information($"DevicesServer started on port {Port}"); } diff --git a/KitX Clients/KitX Core/KitX.Core/Device/PluginsServer.cs b/KitX Clients/KitX Core/KitX.Core/Device/PluginsServer.cs index c96fe859..1a259ce4 100644 --- a/KitX Clients/KitX Core/KitX.Core/Device/PluginsServer.cs +++ b/KitX Clients/KitX Core/KitX.Core/Device/PluginsServer.cs @@ -3,7 +3,9 @@ using System.Text.RegularExpressions; using System.Threading.Tasks; using Fleck; +using KitX.Core; using KitX.Core.Contract.Plugin; +using KitX.Core.Event; using KitX.Shared.CSharp.Plugin; using Serilog; using CTask = System.Threading.Tasks.Task; @@ -81,7 +83,10 @@ private PluginsServer() { } /// private void InitializeServer() { - var port = 7777; + // Use configured port from ConstantTable, or default to 0 (dynamic port) + var port = ConstantTable.PluginsServerPort > 0 + ? ConstantTable.PluginsServerPort + : 0; // 0 means dynamic port assignment port = port is >= 0 and <= 65535 ? port : 0; @@ -89,7 +94,7 @@ private void InitializeServer() } /// - /// Runs the plugins server + /// Runs the plugins server with retry logic for port conflicts /// /// The server instance public IPluginServer Run() @@ -99,92 +104,154 @@ public IPluginServer Run() _status = ServerStatus.Starting; - InitializeServer(); + const int maxRetries = 5; + const int startPort = 7777; + int currentPort = startPort; + bool serverStarted = false; - _server!.Start(socket => + for (int retryCount = 0; retryCount < maxRetries && !serverStarted; retryCount++) { - var connectionId = socket.ConnectionInfo.Path.Trim('/'); - - if (RegexToVerifyConnectionId().IsMatch(connectionId) == false) + try { - socket.Send("Invalid connection id."); - socket.Close(); - return; - } + // Try different ports if not using dynamic port + if (ConstantTable.PluginsServerPort <= 0) + { + currentPort = startPort + retryCount; + _server = new WebSocketServer($"ws://0.0.0.0:{currentPort}"); + } - var connection = new PluginConnection(socket, connectionId); - _connections.Add(connection); + InitializeServer(); - connection.MessageReceived += (sender, message) => - { - try + _server!.Start(socket => { - var kwc = System.Text.Json.JsonSerializer.Deserialize(message); - if (kwc?.Content is not null) + var connectionId = socket.ConnectionInfo.Path.Trim('/'); + + if (RegexToVerifyConnectionId().IsMatch(connectionId) == false) + { + socket.Send("Invalid connection id."); + socket.Close(); + return; + } + + var connection = new PluginConnection(socket, connectionId); + _connections.Add(connection); + + // Handle connection closed + connection.Closed += (sender, args) => + { + _connections.Remove(connection); + + Log.Information($"[PluginsServer] Connection closed: {connectionId}, PluginInfo: {connection.PluginInfo?.Name}"); + + // Trigger PluginUnregistered if this connection had a registered plugin + if (connection.PluginInfo is not null) + { + Log.Information($"[PluginsServer] Publishing PluginUnregistered for: {connection.PluginInfo.Name}"); + + PluginUnregistered?.Invoke(this, new PluginUnregisteredEventArgs + { + PluginInfo = connection.PluginInfo + }); + + // Publish event via EventService + EventService.Instance.Publish(EventNames.PluginUnregistered, new PluginEventArgs + { + PluginInfo = connection.PluginInfo + }); + } + + Log.Information($"[PluginsServer] Publishing PluginDisconnected for: {connectionId}"); + + PluginDisconnected?.Invoke(this, new PluginDisconnectedEventArgs + { + ConnectionId = connectionId + }); + + // Publish event via EventService + EventService.Instance.Publish(EventNames.PluginDisconnected, new PluginConnectionEventArgs + { + ConnectionId = connectionId, + PluginInfo = connection.PluginInfo + }); + }; + + connection.MessageReceived += (sender, message) => { - var cmd = System.Text.Json.JsonSerializer.Deserialize(kwc.Content); - if (cmd.Request == KitX.Shared.CSharp.WebCommand.Infos.CommandRequestInfo.RegisterPlugin) + try { - var body = System.Text.Encoding.UTF8.GetString(cmd.Body.AsSpan(0, cmd.BodyLength).ToArray()); - var pluginInfo = System.Text.Json.JsonSerializer.Deserialize(body); - if (pluginInfo is not null) + var kwc = System.Text.Json.JsonSerializer.Deserialize(message); + if (kwc?.Content is not null) { - pluginInfo.Tags ??= new(); - pluginInfo.Tags[nameof(PluginConnection.ConnectionId)] = connectionId; - pluginInfo.Tags["JoinTime"] = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss(FF)"); - connection.PluginInfo = pluginInfo; - PluginRegistered?.Invoke(this, new PluginRegisteredEventArgs + var cmd = System.Text.Json.JsonSerializer.Deserialize(kwc.Content); + if (cmd.Request == KitX.Shared.CSharp.WebCommand.Infos.CommandRequestInfo.RegisterPlugin) { - PluginInfo = pluginInfo - }); + var body = System.Text.Encoding.UTF8.GetString(cmd.Body.AsSpan(0, cmd.BodyLength).ToArray()); + var pluginInfo = System.Text.Json.JsonSerializer.Deserialize(body); + if (pluginInfo is not null) + { + pluginInfo.Tags ??= new(); + pluginInfo.Tags[nameof(PluginConnection.ConnectionId)] = connectionId; + pluginInfo.Tags["JoinTime"] = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss(FF)"); + connection.PluginInfo = pluginInfo; + PluginRegistered?.Invoke(this, new PluginRegisteredEventArgs + { + PluginInfo = pluginInfo + }); + + // Publish event via EventService + EventService.Instance.Publish(EventNames.PluginRegistered, new PluginEventArgs + { + PluginInfo = pluginInfo + }); + } + } } } - } - } - catch (Exception ex) - { - Log.Warning(ex, "Error handling plugin message"); - } + catch (Exception ex) + { + Log.Warning(ex, "Error handling plugin message"); + } - PluginMessageReceived?.Invoke(this, new PluginMessageReceivedEventArgs - { - ConnectionId = connectionId, - Message = message - }); - }; + PluginMessageReceived?.Invoke(this, new PluginMessageReceivedEventArgs + { + ConnectionId = connectionId, + Message = message + }); + }; - connection.Closed += (sender, args) => - { - _connections.Remove(connection); + connection.Initialize(); - // Trigger PluginUnregistered if this connection had a registered plugin - if (connection.PluginInfo is not null) - { - PluginUnregistered?.Invoke(this, new PluginUnregisteredEventArgs + PluginConnected?.Invoke(this, new PluginConnectedEventArgs { - PluginInfo = connection.PluginInfo + ConnectionId = connectionId }); - } - - PluginDisconnected?.Invoke(this, new PluginDisconnectedEventArgs - { - ConnectionId = connectionId }); - }; - connection.Initialize(); + Port = _server!.Port; + serverStarted = true; - PluginConnected?.Invoke(this, new PluginConnectedEventArgs - { - ConnectionId = connectionId - }); - }); + // Update ConstantTable with the actual port + ConstantTable.PluginsServerPort = Port ?? 0; - Port = _server!.Port; + Log.Information($"PluginsServer started on port {Port}"); - Log.Information($"PluginsServer started on port {Port}"); + // Publish port changed event via EventService only (removed direct PortChanged event to avoid potential recursion) + EventService.Instance.Publish(EventNames.PluginsServerPortChanged, new PortChangedEventArgs { Port = Port ?? 0 }); + } + catch (Exception ex) when (ex.Message.Contains("access") || ex.Message.Contains("used")) + { + Log.Warning($"Port {currentPort} is in use, trying next port... ({retryCount + 1}/{maxRetries})"); + _server?.Dispose(); + _server = null; + } + } - PortChanged?.Invoke(this, Port ?? 0); + if (!serverStarted) + { + Log.Error($"Failed to start PluginsServer after {maxRetries} attempts"); + _status = ServerStatus.Errored; + return this; + } _status = ServerStatus.Running; @@ -395,7 +462,10 @@ public void Initialize() _connection.OnError = ex => { _status = ServerStatus.Errored; - Serilog.Log.Error(ex, $"PluginConnection error for {ConnectionId}"); + Serilog.Log.Error(ex, $"PluginConnection error for {ConnectionId}, triggering Closed event"); + + // Also trigger Closed event when error occurs (e.g., remote host disconnected abruptly) + Closed?.Invoke(this, EventArgs.Empty); }; } diff --git a/KitX Clients/KitX Core/KitX.Core/Event/EventArgs.cs b/KitX Clients/KitX Core/KitX.Core/Event/EventArgs.cs index 5fb53ea3..14176dd4 100644 --- a/KitX Clients/KitX Core/KitX.Core/Event/EventArgs.cs +++ b/KitX Clients/KitX Core/KitX.Core/Event/EventArgs.cs @@ -1,4 +1,5 @@ using System; +using KitX.Shared.CSharp.Plugin; namespace KitX.Core.Event; @@ -34,3 +35,30 @@ public class DeviceInfoEventArgs : EventArgs /// public KitX.Shared.CSharp.Device.DeviceInfo? DeviceInfo { get; set; } } + +/// +/// Event args for plugin events +/// +public class PluginEventArgs : EventArgs +{ + /// + /// The plugin information + /// + public PluginInfo? PluginInfo { get; set; } +} + +/// +/// Event args for plugin connection events +/// +public class PluginConnectionEventArgs : EventArgs +{ + /// + /// The connection ID + /// + public string ConnectionId { get; set; } = string.Empty; + + /// + /// The plugin information + /// + public PluginInfo? PluginInfo { get; set; } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Event/EventNames.cs b/KitX Clients/KitX Core/KitX.Core/Event/EventNames.cs index f7695911..45d2cfb9 100644 --- a/KitX Clients/KitX Core/KitX.Core/Event/EventNames.cs +++ b/KitX Clients/KitX Core/KitX.Core/Event/EventNames.cs @@ -89,4 +89,24 @@ public static class EventNames /// Accepting device key event /// public const string OnAcceptingDeviceKey = "OnAcceptingDeviceKey"; + + /// + /// Plugin connected event + /// + public const string PluginConnected = "PluginConnected"; + + /// + /// Plugin disconnected event + /// + public const string PluginDisconnected = "PluginDisconnected"; + + /// + /// Plugin registered event + /// + public const string PluginRegistered = "PluginRegistered"; + + /// + /// Plugin unregistered event + /// + public const string PluginUnregistered = "PluginUnregistered"; } diff --git a/KitX Clients/KitX Core/KitX.Core/Event/EventService.cs b/KitX Clients/KitX Core/KitX.Core/Event/EventService.cs index acb2bcf5..4570b45c 100644 --- a/KitX Clients/KitX Core/KitX.Core/Event/EventService.cs +++ b/KitX Clients/KitX Core/KitX.Core/Event/EventService.cs @@ -1,8 +1,10 @@ using System; using System.Collections.Generic; using System.ComponentModel; +using System.Threading; using KitX.Core.Contract.Event; using KitX.Shared.CSharp.Device; +using Serilog; namespace KitX.Core.Event; @@ -20,6 +22,21 @@ public class EventService : IEventService private readonly Dictionary>> _eventHandlers = new(); + /// + /// Lock object for thread-safe access to handlers + /// + private readonly object _lock = new(); + + /// + /// Thread-local counter to track publish depth for recursion detection + /// + private readonly ThreadLocal _publishDepth = new(); + + /// + /// Maximum publish depth before considering it recursive + /// + private const int MaxPublishDepth = 10; + /// /// Private constructor /// @@ -32,12 +49,15 @@ private EventService() { } /// The event handler public void Subscribe(string eventName, EventHandler handler) { - if (!_eventHandlers.ContainsKey(eventName)) + lock (_lock) { - _eventHandlers[eventName] = new List>(); - } + if (!_eventHandlers.ContainsKey(eventName)) + { + _eventHandlers[eventName] = new List>(); + } - _eventHandlers[eventName].Add(handler); + _eventHandlers[eventName].Add(handler); + } } /// @@ -47,9 +67,12 @@ public void Subscribe(string eventName, EventHandler handler) /// The event handler public void Unsubscribe(string eventName, EventHandler handler) { - if (_eventHandlers.TryGetValue(eventName, out var handlers)) + lock (_lock) { - handlers.Remove(handler); + if (_eventHandlers.TryGetValue(eventName, out var handlers)) + { + handlers.Remove(handler); + } } } @@ -60,13 +83,30 @@ public void Unsubscribe(string eventName, EventHandler handler) /// The event arguments public void Publish(string eventName, EventArgs args) { - if (_eventHandlers.TryGetValue(eventName, out var handlers)) + // Prevent excessive recursion + _publishDepth.Value = (_publishDepth.Value ?? 0) + 1; + if (_publishDepth.Value > MaxPublishDepth) { - foreach (var handler in handlers) + Log.Error("[EventService] Possible infinite recursion detected! Event: {EventName}, Depth: {Depth}", + eventName, _publishDepth.Value); + _publishDepth.Value = (_publishDepth.Value ?? 1) - 1; + return; + } + + try + { + if (_eventHandlers.TryGetValue(eventName, out var handlers)) { - handler.Invoke(this, args); + foreach (var handler in handlers) + { + handler.Invoke(this, args); + } } } + finally + { + _publishDepth.Value = (_publishDepth.Value ?? 1) - 1; + } } /// @@ -78,18 +118,21 @@ public void Publish(string eventName, EventArgs args) public void Subscribe(string eventName, EventHandler handler) where TEventArgs : EventArgs { - if (!_eventHandlers.ContainsKey(eventName)) - { - _eventHandlers[eventName] = new List>(); - } - - _eventHandlers[eventName].Add((sender, args) => + lock (_lock) { - if (args is TEventArgs typedArgs) + if (!_eventHandlers.ContainsKey(eventName)) { - handler(sender, typedArgs); + _eventHandlers[eventName] = new List>(); } - }); + + _eventHandlers[eventName].Add((sender, args) => + { + if (args is TEventArgs typedArgs) + { + handler(sender, typedArgs); + } + }); + } } /// @@ -118,7 +161,8 @@ public void Unsubscribe(string eventName, EventHandler h public void Publish(string eventName, TEventArgs args) where TEventArgs : EventArgs { - Publish(eventName, args); + // Must cast to EventArgs to call the non-generic overload, avoiding infinite recursion + Publish(eventName, (EventArgs)args); } /// diff --git a/KitX Clients/KitX Core/KitX.Core/KitX.Core.csproj b/KitX Clients/KitX Core/KitX.Core/KitX.Core.csproj index 96ee7c02..5dc6010f 100644 --- a/KitX Clients/KitX Core/KitX.Core/KitX.Core.csproj +++ b/KitX Clients/KitX Core/KitX.Core/KitX.Core.csproj @@ -22,6 +22,7 @@ + diff --git a/KitX Clients/KitX Core/KitX.Core/Plugin/PluginsManager.cs b/KitX Clients/KitX Core/KitX.Core/Plugin/PluginsManager.cs index 0ae48d53..82b0dc72 100644 --- a/KitX Clients/KitX Core/KitX.Core/Plugin/PluginsManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/Plugin/PluginsManager.cs @@ -36,7 +36,87 @@ public class PluginsManager : IPluginService /// /// Private constructor /// - private PluginsManager() { } + private PluginsManager() + { + // Load installed plugins on startup + LoadInstalledPlugins(); + } + + /// + /// Loads installed plugins from the plugins directory + /// + private void LoadInstalledPlugins() + { + try + { + var pluginsDir = Path.GetFullPath("./Data/Plugins/"); + if (!Directory.Exists(pluginsDir)) + { + Log.Information("Plugins directory does not exist, creating: {Dir}", pluginsDir); + Directory.CreateDirectory(pluginsDir); + return; + } + + var pluginDirs = Directory.GetDirectories(pluginsDir); + Log.Information("Found {Count} plugin directories to load", pluginDirs.Length); + + foreach (var pluginDir in pluginDirs) + { + try + { + var pluginInfoPath = Path.Combine(pluginDir, "PluginInfo.json"); + var loaderInfoPath = Path.Combine(pluginDir, "LoaderInfo.json"); + + if (!File.Exists(pluginInfoPath)) + { + Log.Warning("PluginInfo.json not found in {Dir}", pluginDir); + continue; + } + + // Read PluginInfo.json + var pluginInfoJson = File.ReadAllText(pluginInfoPath); + var pluginInfo = JsonSerializer.Deserialize(pluginInfoJson); + + if (pluginInfo == null) + { + Log.Warning("Failed to deserialize PluginInfo in {Dir}", pluginDir); + continue; + } + + // Read LoaderInfo.json if exists + LoaderInfo? loaderInfo = null; + if (File.Exists(loaderInfoPath)) + { + var loaderInfoJson = File.ReadAllText(loaderInfoPath); + loaderInfo = JsonSerializer.Deserialize(loaderInfoJson); + } + + // Create installation record + var installation = new PluginInstallation + { + Id = GeneratePluginId(pluginInfo), + InstallPath = pluginDir, + PluginInfo = pluginInfo, + LoaderInfo = loaderInfo ?? new LoaderInfo(), + InstalledDevices = new List() + }; + + _plugins.Add(installation); + Log.Information("Loaded plugin: {Name} v{Version} from {Dir}", pluginInfo.Name, pluginInfo.Version, pluginDir); + } + catch (Exception ex) + { + Log.Error(ex, "Error loading plugin from {Dir}", pluginDir); + } + } + + Log.Information("Loaded {Count} installed plugins", _plugins.Count); + } + catch (Exception ex) + { + Log.Error(ex, "Error loading installed plugins"); + } + } /// /// Gets all installed plugins (alias for GetInstalledPlugins) @@ -84,7 +164,7 @@ public IReadOnlyList GetInstalledPlugins() /// The plugin installation or null if not found public IPluginInstallation? GetPlugin(Guid pluginId) { - return _plugins.FirstOrDefault(p => GeneratePluginId(p.PluginInfo!) == pluginId); + return _plugins.FirstOrDefault(p => p.Id == pluginId); } /// @@ -92,7 +172,7 @@ public IReadOnlyList GetInstalledPlugins() /// /// The plugin info /// The generated plugin ID - private Guid GeneratePluginId(PluginInfo pluginInfo) + public static Guid GeneratePluginId(PluginInfo pluginInfo) { // Generate deterministic GUID from: PublisherName_AuthorName_Name_Version var input = $"{pluginInfo.PublisherName}_{pluginInfo.AuthorName}_{pluginInfo.Name}_{pluginInfo.Version}"; @@ -122,44 +202,234 @@ public async Task ImportPluginAsync(string kxpFilePath) return false; } - // TODO: Implement actual KXP file decoding - // For now, we'll create a placeholder installation - var pluginInfo = new PluginInfo + // Get the plugins directory + var pluginsDir = Path.GetFullPath("./Data/Plugins/"); + if (!Directory.Exists(pluginsDir)) { - Name = Path.GetFileNameWithoutExtension(kxpFilePath), - Version = "1.0.0", - PublisherName = "Unknown", - AuthorName = "Unknown" - }; + Directory.CreateDirectory(pluginsDir); + } + + // Generate a unique plugin ID based on filename + var pluginFileName = Path.GetFileNameWithoutExtension(kxpFilePath); + var pluginDir = Path.Combine(pluginsDir, pluginFileName); + + // Handle duplicate plugin names + var counter = 1; + while (Directory.Exists(pluginDir)) + { + pluginDir = Path.Combine(pluginsDir, $"{pluginFileName}_{counter}"); + counter++; + } + + Directory.CreateDirectory(pluginDir); + + // Decode the KXP file + try + { + var decoder = new KitX.FileFormats.CSharp.ExtensionsPackage.Decoder(kxpFilePath); + decoder.Decode(pluginDir); + Log.Information($"Decoded KXP file to: {pluginDir}"); + } + catch (Exception ex) + { + Log.Warning(ex, "Failed to decode KXP file, trying as direct files..."); + // If KXP decode fails, assume it's a directory with files already extracted + // Just copy the source directory contents + var sourceDir = Path.GetDirectoryName(kxpFilePath); + if (sourceDir != null && Directory.Exists(sourceDir)) + { + foreach (var file in Directory.GetFiles(sourceDir)) + { + var destFile = Path.Combine(pluginDir, Path.GetFileName(file)); + if (!File.Exists(destFile)) + { + File.Copy(file, destFile); + } + } + } + } + + // Look for LoaderStruct.json and PluginStruct.json in the extracted files + var loaderStructPath = Path.Combine(pluginDir, "LoaderStruct.json"); + var pluginStructPath = Path.Combine(pluginDir, "PluginStruct.json"); + + KitX.Shared.CSharp.Plugin.PluginInfo? pluginInfo = null; + KitX.Shared.CSharp.Loader.LoaderInfo? loaderInfo = null; + + // Parse LoaderStruct.json if exists + if (File.Exists(loaderStructPath)) + { + try + { + var loaderStructJson = await File.ReadAllTextAsync(loaderStructPath); + var loaderStruct = System.Text.Json.JsonSerializer.Deserialize(loaderStructJson); + + loaderInfo = new KitX.Shared.CSharp.Loader.LoaderInfo + { + LoaderName = loaderStruct.TryGetProperty("LoaderName", out var name) ? name.GetString() : "Unknown", + LoaderVersion = loaderStruct.TryGetProperty("LoaderVersion", out var version) ? version.GetString() : "1.0.0", + LoaderLanguage = loaderStruct.TryGetProperty("LoaderLanguage", out var lang) ? lang.GetString() : "Unknown", + LoaderFramework = loaderStruct.TryGetProperty("LoaderFramework", out var fw) ? fw.GetString() : "Unknown", + SelfLoad = loaderStruct.TryGetProperty("SelfLoad", out var selfLoad) && selfLoad.GetBoolean(), + Tags = new Dictionary() + }; + + // Copy loader struct to LoaderInfo.json (different format) + var loaderInfoJson = System.Text.Json.JsonSerializer.Serialize(loaderInfo, new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); + await File.WriteAllTextAsync(Path.Combine(pluginDir, "LoaderInfo.json"), loaderInfoJson); + + Log.Information($"Parsed LoaderStruct: {loaderInfo.LoaderName} v{loaderInfo.LoaderVersion}"); + } + catch (Exception ex) + { + Log.Warning(ex, "Failed to parse LoaderStruct.json"); + } + } + + // Parse PluginStruct.json if exists (or look for embedded plugin info) + if (File.Exists(pluginStructPath)) + { + try + { + var pluginStructJson = await File.ReadAllTextAsync(pluginStructPath); + var pluginStruct = System.Text.Json.JsonSerializer.Deserialize(pluginStructJson); + + pluginInfo = new KitX.Shared.CSharp.Plugin.PluginInfo + { + Name = pluginStruct.TryGetProperty("Name", out var name) ? name.GetString() ?? "Unknown" : "Unknown", + Version = pluginStruct.TryGetProperty("Version", out var ver) ? ver.GetString() ?? "1.0.0" : "1.0.0", + DisplayName = new Dictionary(), + SimpleDescription = new Dictionary(), + ComplexDescription = new Dictionary(), + TotalDescriptionInMarkdown = new Dictionary(), + Tags = new Dictionary(), + Functions = new List() + }; + + // Parse DisplayName + if (pluginStruct.TryGetProperty("DisplayName", out var displayName)) + { + foreach (var prop in displayName.EnumerateObject()) + { + pluginInfo.DisplayName[prop.Name] = prop.Value.GetString() ?? ""; + } + } + + // Parse SimpleDescription + if (pluginStruct.TryGetProperty("SimpleDescription", out var simpleDesc)) + { + foreach (var prop in simpleDesc.EnumerateObject()) + { + pluginInfo.SimpleDescription[prop.Name] = prop.Value.GetString() ?? ""; + } + } + + // Parse ComplexDescription + if (pluginStruct.TryGetProperty("ComplexDescription", out var complexDesc)) + { + foreach (var prop in complexDesc.EnumerateObject()) + { + pluginInfo.ComplexDescription[prop.Name] = prop.Value.GetString() ?? ""; + } + } + + // Parse other fields + pluginInfo.AuthorName = pluginStruct.TryGetProperty("AuthorName", out var author) ? author.GetString() : "Unknown"; + pluginInfo.AuthorLink = pluginStruct.TryGetProperty("AuthorLink", out var authorLink) ? authorLink.GetString() : ""; + pluginInfo.PublisherName = pluginStruct.TryGetProperty("PublisherName", out var publisher) ? publisher.GetString() : "Unknown"; + pluginInfo.PublisherLink = pluginStruct.TryGetProperty("PublisherLink", out var publisherLink) ? publisherLink.GetString() : ""; + pluginInfo.IconInBase64 = pluginStruct.TryGetProperty("IconInBase64", out var icon) ? icon.GetString() : ""; + pluginInfo.IsMarketVersion = pluginStruct.TryGetProperty("IsMarketVersion", out var marketVer) && marketVer.GetBoolean(); + pluginInfo.RootStartupFileName = pluginStruct.TryGetProperty("RootStartupFileName", out var rootFile) ? rootFile.GetString() : ""; + + if (pluginStruct.TryGetProperty("PublishDate", out var publishDate)) + { + if (DateTime.TryParse(publishDate.GetString(), out var pd)) + pluginInfo.PublishDate = pd; + } + + if (pluginStruct.TryGetProperty("LastUpdateDate", out var lastUpdate)) + { + if (DateTime.TryParse(lastUpdate.GetString(), out var lud)) + pluginInfo.LastUpdateDate = lud; + } + + Log.Information($"Parsed PluginStruct: {pluginInfo.Name} v{pluginInfo.Version}"); + } + catch (Exception ex) + { + Log.Warning(ex, "Failed to parse PluginStruct.json"); + } + } + + // If no plugin info from KXP, create basic info from filename + if (pluginInfo == null) + { + pluginInfo = new KitX.Shared.CSharp.Plugin.PluginInfo + { + Name = pluginFileName, + Version = "1.0.0", + PublisherName = "Unknown", + AuthorName = "Unknown", + DisplayName = new Dictionary { { "en-us", pluginFileName } }, + SimpleDescription = new Dictionary { { "en-us", "Imported plugin" } }, + ComplexDescription = new Dictionary { { "en-us", "Imported plugin" } }, + TotalDescriptionInMarkdown = new Dictionary(), + Tags = new Dictionary(), + Functions = new List() + }; + } + + // Validate RootStartupFileName is specified and exists + if (string.IsNullOrEmpty(pluginInfo.RootStartupFileName)) + { + Log.Error($"Plugin import failed: RootStartupFileName is not specified in plugin {pluginInfo.Name}. Please ensure the plugin package includes this field."); + return false; + } + + var pluginFilePath = Path.Combine(pluginDir, pluginInfo.RootStartupFileName); + if (!File.Exists(pluginFilePath)) + { + Log.Error($"Plugin import failed: RootStartupFileName '{pluginInfo.RootStartupFileName}' points to a non-existent file in plugin {pluginInfo.Name}. File not found at: {pluginFilePath}"); + // Clean up the created directory + try { Directory.Delete(pluginDir, true); } catch { } + return false; + } + + // Create PluginInfo.json + var pluginInfoJson = System.Text.Json.JsonSerializer.Serialize(pluginInfo, new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); + await File.WriteAllTextAsync(Path.Combine(pluginDir, "PluginInfo.json"), pluginInfoJson); + + // Create installation record var installation = new PluginInstallation { - InstallPath = Path.GetDirectoryName(kxpFilePath) ?? "./Plugins/", + Id = GeneratePluginId(pluginInfo), + InstallPath = pluginDir, PluginInfo = pluginInfo, - LoaderInfo = new LoaderInfo(), - InstalledDevices = new List() + LoaderInfo = loaderInfo ?? new KitX.Shared.CSharp.Loader.LoaderInfo(), + InstalledDevices = new List() }; - var generatedId = GeneratePluginId(pluginInfo); _plugins.Add(installation); - Log.Information($"Imported plugin: {pluginInfo.Name} (v{pluginInfo.Version})"); + Log.Information($"Imported plugin: {pluginInfo.Name} (v{pluginInfo.Version}) to {pluginDir}"); // Raise plugin status changed event PluginStatusChanged?.Invoke(this, new PluginStatusChangedEventArgs { - PluginId = generatedId, + PluginId = installation.Id, PluginName = pluginInfo.Name, OldStatus = PluginStatus.Unknown, NewStatus = PluginStatus.Installed }); - return await System.Threading.Tasks.Task.FromResult(true); + return true; } catch (Exception ex) { Log.Error(ex, $"In {location}: Error importing plugin {kxpFilePath}: {ex.Message}"); - return await System.Threading.Tasks.Task.FromResult(false); + return false; } } @@ -353,6 +623,11 @@ public async Task StopPluginAsync(Guid pluginId) /// public class PluginInstallation : IPluginInstallation { + /// + /// Gets the unique identifier for this plugin installation + /// + public Guid Id { get; set; } + public string? InstallPath { get; set; } public PluginInfo? PluginInfo { get; set; } public LoaderInfo? LoaderInfo { get; set; } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/RealPluginManager.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/RealPluginManager.cs new file mode 100644 index 00000000..71c47e8c --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/RealPluginManager.cs @@ -0,0 +1,271 @@ +using System; +using System.Collections.Concurrent; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Kscript.CSharp.Parser.Core; +using Kscript.CSharp.Parser.Models; +using KitX.Core.Device; +using KitX.Shared.CSharp.Plugin; +using KitX.Shared.CSharp.WebCommand; +using KitX.Shared.CSharp.WebCommand.Infos; +using Serilog; + +namespace KitX.Core.Workflow; + +/// +/// 真实的插件管理器实现,通过 WebSocket 与插件通信 +/// +public class RealPluginManager : IPluginManager +{ + private readonly PluginsServer _pluginsServer; + private readonly JsonSerializerOptions _serializerOptions = new() + { + WriteIndented = true, + IncludeFields = true, + PropertyNameCaseInsensitive = true, + }; + + /// + /// 待处理的响应字典,用于异步等待插件响应 + /// + private readonly ConcurrentDictionary> _pendingResponses = new(); + + /// + /// 无参构造函数(供 KScript.Parser 动态创建实例使用) + /// + public RealPluginManager() : this(PluginsServer.Instance) + { + } + + /// + /// 构造函数 + /// + /// 插件服务器实例 + public RealPluginManager(PluginsServer pluginsServer) + { + _pluginsServer = pluginsServer; + + // 订阅插件消息接收事件以处理响应 + _pluginsServer.PluginMessageReceived += OnPluginMessageReceived; + } + + /// + /// 处理收到的插件消息 + /// + private void OnPluginMessageReceived(object? sender, PluginMessageReceivedEventArgs e) + { + try + { + var kwc = JsonSerializer.Deserialize(e.Message, _serializerOptions); + if (kwc?.Content is null) return; + + var command = JsonSerializer.Deserialize(kwc.Content, _serializerOptions); + if (command.Request is null) return; // Command is a struct, check if Request is empty + + // 检查是否是响应消息 + if (command.Tags != null && command.Tags.TryGetValue("RequestId", out var requestId)) + { + if (_pendingResponses.TryRemove(requestId, out var tcs)) + { + var responseBody = command.BodyLength > 0 + ? Encoding.UTF8.GetString(command.Body.AsSpan(0, command.BodyLength)) + : string.Empty; + tcs.SetResult(responseBody); + } + } + } + catch (Exception ex) + { + Log.Warning(ex, "[RealPluginManager] Error handling plugin message"); + } + } + + /// + /// 调用插件方法(无返回值) + /// + public void Call(PluginCallInfo callInfo) + { + Log.Information($"[RealPluginManager] Call() invoked: {callInfo.PluginName}.{callInfo.MethodName}"); + CallAsync(callInfo).Wait(); + } + + /// + /// 调用插件方法(有返回值) + /// + public T Call(PluginCallInfo callInfo) + { + Log.Information($"[RealPluginManager] Call<{typeof(T).Name}>() invoked: {callInfo.PluginName}.{callInfo.MethodName}"); + var result = CallAsync(callInfo).GetAwaiter().GetResult(); + return ParseResult(result); + } + + /// + /// 异步调用插件方法 + /// + private async Task CallAsync(PluginCallInfo callInfo) + { + const string location = $"{nameof(RealPluginManager)}.{nameof(CallAsync)}"; + + // 查找插件连接 + var connection = FindPluginConnection(callInfo.PluginName); + if (connection is null) + { + Log.Error($"[RealPluginManager] Plugin connection not found: {callInfo.PluginName}"); + Log.Information($"[RealPluginManager] Available connections: {_pluginsServer.Connections.Count}"); + foreach (var conn in _pluginsServer.Connections) + { + Log.Information($"[RealPluginManager] Connection: {conn.ConnectionId}, Plugin: {conn.PluginInfo?.Name}"); + } + throw new InvalidOperationException($"Plugin not found or not connected: {callInfo.PluginName}"); + } + + Log.Information($"[RealPluginManager] Found connection for {callInfo.PluginName}, ConnectionId: {connection.ConnectionId}"); + + // 生成唯一请求ID + var requestId = Guid.NewGuid().ToString(); + + // 创建任务CompletionSource用于等待响应 + var tcs = new TaskCompletionSource(); + _pendingResponses[requestId] = tcs; + + try + { + // 构建命令 + var command = new Command + { + Request = CommandRequestInfo.ReceiveCommand, + FunctionName = callInfo.MethodName, + PluginConnectionId = connection.ConnectionId ?? string.Empty, + Tags = new() + { + { "RequestId", requestId } + } + }; + + // 处理参数 + if (callInfo.Parameters != null && callInfo.Parameters.Length > 0) + { + command.FunctionArgs = new(); + for (int i = 0; i < callInfo.Parameters.Length; i++) + { + var paramValue = callInfo.Parameters[i]?.ToString() ?? string.Empty; + command.FunctionArgs.Add(new Parameter + { + Name = i.ToString(), + Value = paramValue, + Type = callInfo.ParameterTypes?.Length > i + ? callInfo.ParameterTypes[i].Name.ToLower() + : "string" + }); + } + command.Body = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(command.FunctionArgs, _serializerOptions)); + command.BodyLength = command.Body.Length; + } + + // 构建请求 + var request = new Request + { + Type = RequestTypes.Command, + Version = RequestVersions.V1, + Content = JsonSerializer.Serialize(command, _serializerOptions) + }; + + // 发送请求 + var message = JsonSerializer.Serialize(request, _serializerOptions); + connection.Send(message); + + Log.Information($"[RealPluginManager] Sent request to {callInfo.PluginName}.{callInfo.MethodName}, RequestId: {requestId}"); + + // 注意: Loader 在处理 ReceiveCommand 后不会返回响应 + // 对于 void 方法,我们直接返回空结果 + // 对于有返回值的方法,需要插件支持返回响应(当前不支持) + return string.Empty; + } + catch (Exception ex) + { + Log.Error(ex, $"[RealPluginManager] Error calling {callInfo.PluginName}.{callInfo.MethodName}"); + throw; + } + } + + /// + /// 解析结果 + /// + private T ParseResult(string result) + { + if (result is null || typeof(T) == typeof(void)) + return default!; + + try + { + var returnType = typeof(T); + + // 处理基础类型 + if (returnType == typeof(string)) + return (T)(object)result; + + if (returnType == typeof(int)) + return (T)(object)int.Parse(result); + + if (returnType == typeof(long)) + return (T)(object)long.Parse(result); + + if (returnType == typeof(float)) + return (T)(object)float.Parse(result); + + if (returnType == typeof(double)) + return (T)(object)double.Parse(result); + + if (returnType == typeof(bool)) + return (T)(object)bool.Parse(result); + + // 对于复杂类型,从 JSON 反序列化 + return JsonSerializer.Deserialize(result, _serializerOptions)!; + } + catch (Exception ex) + { + Log.Warning(ex, $"[RealPluginManager] Error parsing result: {result}"); + return default!; + } + } + + /// + /// 检查插件是否存在 + /// + public bool IsPluginExists(string pluginName) + { + var exists = FindPluginConnection(pluginName) != null; + Log.Information($"[RealPluginManager] IsPluginExists({pluginName}): {exists}"); + if (!exists) + { + foreach (var conn in _pluginsServer.Connections) + { + Log.Information($"[RealPluginManager] Available connection: {conn.ConnectionId}, Plugin: {conn.PluginInfo?.Name}"); + } + } + return exists; + } + + /// + /// 检查插件方法是否存在 + /// + public bool IsMethodExists(string pluginName, string methodName) + { + var connection = FindPluginConnection(pluginName); + if (connection?.PluginInfo?.Functions == null) + return false; + + return connection.PluginInfo.Functions.Exists(f => f.Name == methodName); + } + + /// + /// 查找插件连接 + /// + private IPluginConnection? FindPluginConnection(string pluginName) + { + return _pluginsServer.Connections + .FirstOrDefault(c => c.PluginInfo?.Name == pluginName); + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowOutput.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowOutput.cs new file mode 100644 index 00000000..b28e5e44 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowOutput.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Concurrent; +using System.Text; + +namespace KitX.Core.Workflow; + +/// +/// 工作流脚本输出辅助类 +/// 用户可以使用 WorkflowOutput.WriteLine() 来输出调试信息 +/// +public static class WorkflowOutput +{ + private static readonly ConcurrentQueue _outputQueue = new(); + + /// + /// 写入一行输出 + /// + public static void WriteLine(object? value) + { + _outputQueue.Enqueue(value?.ToString() ?? "null"); + } + + /// + /// 写入一行输出(格式化) + /// + public static void WriteLine(string format, params object[] args) + { + _outputQueue.Enqueue(string.Format(format, args)); + } + + /// + /// 获取所有累积的输出并清空队列 + /// + public static string GetAndClear() + { + var sb = new StringBuilder(); + while (_outputQueue.TryDequeue(out var line)) + { + sb.AppendLine(line); + } + return sb.ToString(); + } + + /// + /// 检查是否有输出 + /// + public static bool HasOutput => !_outputQueue.IsEmpty; +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs index 3564fe31..b6b2d3c8 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.IO; using System.Linq; using System.Reflection; using System.Text; @@ -11,6 +12,7 @@ using Microsoft.CodeAnalysis.CSharp.Scripting; using Kscript.CSharp.Parser; using KitX.Core.Contract.Workflow; +using KitX.Core.Device; using KitX.Shared.CSharp.Plugin; using Serilog; using CTask = System.Threading.Tasks.Task; @@ -239,7 +241,8 @@ public void RemoveWorkflow(string workflowId) Assembly? pluginApiAssembly = null; try { - pluginApiAssembly = Parser.Generate(requiredPlugins, "KitXWorkflowPlugins"); + // Disable cache during development to ensure RealPluginManager is used + pluginApiAssembly = Parser.Generate(requiredPlugins, "KitXWorkflowPlugins", useCache: false); Log.Information($"[WorkflowScriptService] Successfully generated plugin API with {requiredPlugins.Count} plugins"); } catch (Exception ex) @@ -301,6 +304,9 @@ public void RemoveWorkflow(string workflowId) { try { + // Clear previous output before execution + WorkflowOutput.GetAndClear(); + var result = await Engine.ExecuteAsync( code, options => @@ -310,6 +316,7 @@ public void RemoveWorkflow(string workflowId) .WithImports( "KitX", "KitX.Core", + "KitX.Core.Workflow", "KitX.Shared.CSharp.Plugin", "System", "System.Collections.Generic", @@ -330,7 +337,18 @@ public void RemoveWorkflow(string workflowId) cancellationToken: cancellationToken ); - return result?.ToString(); + // Get output from WorkflowOutput and combine with return value + var scriptOutput = WorkflowOutput.GetAndClear(); + var returnValue = result?.ToString(); + + if (!string.IsNullOrEmpty(scriptOutput)) + { + return string.IsNullOrEmpty(returnValue) + ? scriptOutput + : $"{scriptOutput}\n{returnValue}"; + } + + return returnValue; } catch (Exception ex) { @@ -374,11 +392,19 @@ private async Task EnsurePluginsReadyAsync( try { + // Clear previous output before execution + WorkflowOutput.GetAndClear(); + var result = await Engine.ExecuteAsync( code, options => options .WithReferences(Assembly.GetExecutingAssembly()) - .WithImports("System", "System.Collections.Generic", "System.Threading.Tasks") + .WithImports( + "System", + "System.Collections.Generic", + "System.Threading.Tasks", + "KitX.Core.Workflow" + ) .WithLanguageVersion(LanguageVersion.Preview), addDefaultImports: true, runInReplMode: false, @@ -387,13 +413,23 @@ private async Task EnsurePluginsReadyAsync( sw.Stop(); + // Get output from WorkflowOutput and combine with return value + var scriptOutput = WorkflowOutput.GetAndClear(); + var returnValue = result?.ToString(); + + var combinedOutput = !string.IsNullOrEmpty(scriptOutput) + ? (string.IsNullOrEmpty(returnValue) + ? scriptOutput + : $"{scriptOutput}\n{returnValue}") + : returnValue; + return includeTimestamp ? new StringBuilder() .AppendLine($"[{begin:yyyy-MM-dd HH:mm:ss}] [I] Workflow script posted.") .AppendLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [I] Script ended, took {sw.ElapsedMilliseconds} ms.") - .AppendLine(result?.ToString()) + .AppendLine(combinedOutput) .ToString() - : result?.ToString(); + : combinedOutput; } catch (Exception ex) { @@ -420,19 +456,18 @@ public void InitializePluginManager() try { - // Create a basic plugin service provider - // Note: In full implementation, this would use the actual PluginsServer - var serviceProvider = new PluginServiceProvider(null); + // Use the real plugin manager that communicates via WebSocket + var pluginsServer = PluginsServer.Instance; + var realPluginManager = new RealPluginManager(pluginsServer); - // Set up the parser with a mock plugin manager - Parser.SetPluginManager(new Kscript.CSharp.Parser.Core.MockPluginManager()); + Parser.SetPluginManager(realPluginManager); _isParserInitialized = true; - Log.Information("[WorkflowScriptService] Plugin manager initialized"); + Log.Information("[WorkflowScriptService] Real plugin manager initialized"); } catch (Exception ex) { - Log.Error($"[WorkflowScriptService] Failed to initialize plugin manager: {ex.Message}"); + Log.Error($"[WorkflowScriptService] Failed to initialize real plugin manager: {ex.Message}, falling back to mock"); // Use mock manager as fallback Parser.SetPluginManager(new Kscript.CSharp.Parser.Core.MockPluginManager()); _isParserInitialized = true; diff --git a/KitX Clients/KitX Dashboard b/KitX Clients/KitX Dashboard index 73e6ab99..53497dd1 160000 --- a/KitX Clients/KitX Dashboard +++ b/KitX Clients/KitX Dashboard @@ -1 +1 @@ -Subproject commit 73e6ab996e26648d99aeaebad22aaeeb1456cae7 +Subproject commit 53497dd15bef1ccce407064ff191c3feef83e709 diff --git a/KitX SDK b/KitX SDK index 1f4e437c..07e5b193 160000 --- a/KitX SDK +++ b/KitX SDK @@ -1 +1 @@ -Subproject commit 1f4e437c10774363b4b3103d18085039519db644 +Subproject commit 07e5b193c4b1d74a9f2d62f400cd6f933d97e877 diff --git a/KitX Standard b/KitX Standard index 4c0b44d2..be9302d5 160000 --- a/KitX Standard +++ b/KitX Standard @@ -1 +1 @@ -Subproject commit 4c0b44d2d80d6ace5f3b047bc299a6ab50ec4bd2 +Subproject commit be9302d5679b69f47cde4d67470dfb7d264e6652 From 776ac01a8943d329ac86c7d29d53639c1984d061 Mon Sep 17 00:00:00 2001 From: StarInk Date: Thu, 5 Mar 2026 09:43:39 +0100 Subject: [PATCH 016/110] =?UTF-8?q?=F0=9F=92=BE=F0=9F=94=A7=20Feat,=20Fix(?= =?UTF-8?q?Core):=20=E5=9C=A8=E6=96=B0=E6=9E=B6=E6=9E=84=E4=B8=AD=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=20IPluginServer.FindConnector=20=E6=96=B9=E6=B3=95?= =?UTF-8?q?=E4=BB=A5=E6=94=AF=E6=8C=81=E6=8F=92=E4=BB=B6=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=E5=99=A8=E7=AA=97=E5=8F=A3=E7=9A=84=E6=8F=92=E4=BB=B6=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E8=B0=83=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../KitX.Core/Device/PluginsServer.cs | 65 ++++++++++++++++++- KitX Clients/KitX Dashboard | 2 +- 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/KitX Clients/KitX Core/KitX.Core/Device/PluginsServer.cs b/KitX Clients/KitX Core/KitX.Core/Device/PluginsServer.cs index 1a259ce4..20c43332 100644 --- a/KitX Clients/KitX Core/KitX.Core/Device/PluginsServer.cs +++ b/KitX Clients/KitX Core/KitX.Core/Device/PluginsServer.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Text.Json; using System.Text.RegularExpressions; using System.Threading.Tasks; using Fleck; @@ -7,6 +8,7 @@ using KitX.Core.Contract.Plugin; using KitX.Core.Event; using KitX.Shared.CSharp.Plugin; +using KitX.Shared.CSharp.WebCommand; using Serilog; using CTask = System.Threading.Tasks.Task; @@ -28,6 +30,16 @@ public class PluginsServer : IPluginServer private readonly List _connections = new(); private ServerStatus _status = ServerStatus.Pending; + /// + /// JSON serializer options (accessible from PluginConnection) + /// + internal static readonly JsonSerializerOptions SerializerOptions = new() + { + WriteIndented = true, + IncludeFields = true, + PropertyNameCaseInsensitive = true, + }; + /// /// Gets the service status /// @@ -273,8 +285,9 @@ public IPluginServer Run() /// The plugin connector or null if not found public IPluginConnector? FindConnector(PluginInfo pluginInfo) { - // Return null for now - actual implementation would find by plugin info - return null; + // Use the existing FindConnection method and cast to IPluginConnector + var connection = FindConnection(pluginInfo); + return connection as IPluginConnector; } /// @@ -397,7 +410,7 @@ public interface IPluginConnection /// /// Plugin connection implementation /// -public class PluginConnection : IPluginConnection +public class PluginConnection : IPluginConnection, IPluginConnector { private readonly IWebSocketConnection _connection; private ServerStatus _status = ServerStatus.Pending; @@ -427,6 +440,16 @@ public class PluginConnection : IPluginConnection /// public event EventHandler? Closed; + /// + /// Event raised when a plugin response is received (IPluginConnector implementation) + /// + public event EventHandler? PluginResponse; + + /// + /// Event raised when plugin reports status (IPluginConnector implementation) + /// + public event EventHandler? StatusReport; + /// /// Constructor /// @@ -450,6 +473,32 @@ public void Initialize() _connection.OnMessage = message => { + // Handle plugin response messages + try + { + var kwc = JsonSerializer.Deserialize(message, PluginsServer.SerializerOptions); + if (kwc?.Content is not null) + { + var command = JsonSerializer.Deserialize(kwc.Content, PluginsServer.SerializerOptions); + if (command.Tags != null && + command.Tags.TryGetValue("RequestId", out var requestId)) + { + // This is a plugin response - trigger PluginResponse event + PluginResponse?.Invoke(this, new PluginResponseEventArgs + { + RequestId = requestId, + Content = kwc.Content + }); + return; + } + } + } + catch (Exception ex) + { + Log.Warning(ex, "Error parsing plugin response message"); + } + + // Forward to MessageReceived for other handlers MessageReceived?.Invoke(this, message); }; @@ -478,6 +527,16 @@ public void Send(string message) _connection.Send(message); } + /// + /// Sends a request to the plugin (IPluginConnector implementation) + /// + /// The request to send + public void Request(object request) + { + var json = JsonSerializer.Serialize(request, PluginsServer.SerializerOptions); + _connection.Send(json); + } + /// /// Closes the connection /// diff --git a/KitX Clients/KitX Dashboard b/KitX Clients/KitX Dashboard index 53497dd1..075cfd75 160000 --- a/KitX Clients/KitX Dashboard +++ b/KitX Clients/KitX Dashboard @@ -1 +1 @@ -Subproject commit 53497dd15bef1ccce407064ff191c3feef83e709 +Subproject commit 075cfd7556e5208e7b0404cdf0828fab9b468958 From 51679b03696070ac2140624fca507469cb770fda Mon Sep 17 00:00:00 2001 From: StarInk Date: Tue, 10 Mar 2026 10:59:27 +0100 Subject: [PATCH 017/110] =?UTF-8?q?=F0=9F=92=BE=20=F0=9F=A7=A9=20Feat,=20R?= =?UTF-8?q?efactor:=20=E8=BF=81=E7=A7=BB=E5=B9=B6=E6=95=B4=E7=90=86?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E4=B8=8E=E8=AE=BE=E5=A4=87=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E7=9B=B8=E5=85=B3=E5=8A=9F=E8=83=BD=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Announcement/AnnouncementManager.cs | 2 +- .../Configuration/AnnouncementConfig.cs | 31 ++ .../KitX.Core/Configuration/AppConfig.cs | 17 +- .../KitX.Core/Configuration/ConfigManager.cs | 323 +++++++++-- .../ConfigSerializationOptions.cs | 16 + .../KitX.Core/Configuration/DeviceKeyImpl.cs | 42 ++ .../KitX.Core/Configuration/PluginsConfig.cs | 45 ++ .../KitX.Core/Configuration/SecurityConfig.cs | 43 ++ .../DI/CoreServiceCollectionExtensions.cs | 4 +- .../KitX.Core/Device/DevicesServer.cs | 513 +++++++++++++++++- .../KitX.Core/Device/PluginsServer.cs | 4 +- .../KitX Core/KitX.Core/Event/EventArgs.cs | 2 +- .../KitX.Core/Plugin/PluginsManager.cs | 28 +- .../KitX.Core/Security/SecurityManager.cs | 296 +++++++++- KitX Clients/KitX Dashboard | 2 +- KitX Standard | 2 +- 16 files changed, 1271 insertions(+), 99 deletions(-) create mode 100644 KitX Clients/KitX Core/KitX.Core/Configuration/AnnouncementConfig.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Configuration/ConfigSerializationOptions.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Configuration/DeviceKeyImpl.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Configuration/PluginsConfig.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Configuration/SecurityConfig.cs diff --git a/KitX Clients/KitX Core/KitX.Core/Announcement/AnnouncementManager.cs b/KitX Clients/KitX Core/KitX.Core/Announcement/AnnouncementManager.cs index 99b31e90..d0046602 100644 --- a/KitX Clients/KitX Core/KitX.Core/Announcement/AnnouncementManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/Announcement/AnnouncementManager.cs @@ -184,7 +184,7 @@ public IReadOnlyList GetReadAnnouncementIds() public void SaveAnnouncementConfig() { var config = AnnouncementConfig; - if (!string.IsNullOrEmpty(config.ConfigFileLocation) && config is Configuration.AnnouncementConfig typedConfig) + if (!string.IsNullOrEmpty(config.ConfigFileLocation) && config is AnnouncementConfig typedConfig) { typedConfig.Save(config.ConfigFileLocation); } diff --git a/KitX Clients/KitX Core/KitX.Core/Configuration/AnnouncementConfig.cs b/KitX Clients/KitX Core/KitX.Core/Configuration/AnnouncementConfig.cs new file mode 100644 index 00000000..87781b27 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Configuration/AnnouncementConfig.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; +using KitX.Core.Contract.Configuration; + +namespace KitX.Core.Configuration; + +/// +/// Announcement configuration implementation +/// +public class AnnouncementConfig : IAnnouncementConfig +{ + /// + /// Gets or sets the list of accepted announcement IDs + /// + public List Accepted { get; set; } = []; + + /// + /// Configuration file location (for backward compatibility) + /// + public string? ConfigFileLocation { get; set; } + + /// + /// Saves the configuration to file (for backward compatibility) + /// + /// File path to save + /// This instance + public AnnouncementConfig Save(string path) + { + // Implementation would save to file - simplified for compatibility + return this; + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Configuration/AppConfig.cs b/KitX Clients/KitX Core/KitX.Core/Configuration/AppConfig.cs index 58d359c9..dca9f80c 100644 --- a/KitX Clients/KitX Core/KitX.Core/Configuration/AppConfig.cs +++ b/KitX Clients/KitX Core/KitX.Core/Configuration/AppConfig.cs @@ -10,8 +10,23 @@ namespace KitX.Core.Configuration; /// Application configuration implementation /// This class contains all application settings organized into logical sections /// -public class AppConfig : IAppConfig +public class AppConfig : IAppConfig, IConfigWithMetadata { + /// + /// Configuration file location + /// + public string? ConfigFileLocation { get; set; } + + /// + /// Configuration file watcher name + /// + public string? ConfigFileWatcherName { get; set; } + + /// + /// Configuration generated time + /// + public DateTime? ConfigGeneratedTime { get; set; } = DateTime.Now; + public Config_App App { get; set; } = new(); public Config_Windows Windows { get; set; } = new(); diff --git a/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigManager.cs b/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigManager.cs index 9b6cb114..1e3e3f09 100644 --- a/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigManager.cs @@ -1,12 +1,10 @@ -using System; +using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Text.Json; -using KitX.Core.Contract.Announcement; using KitX.Core.Contract.Configuration; using KitX.Shared.CSharp.Device; -using KitX.Shared.CSharp.Loader; -using KitX.Shared.CSharp.Plugin; using Serilog; namespace KitX.Core.Configuration; @@ -14,7 +12,7 @@ namespace KitX.Core.Configuration; /// /// Configuration manager for managing application configurations /// -public class ConfigManager : IConfigService +public class ConfigManager : IConfigService, IDisposable { private static ConfigManager? _instance; @@ -27,6 +25,21 @@ public class ConfigManager : IConfigService private readonly Dictionary _configs = new(); + /// + /// File system watchers for hot-reload + /// + private readonly Dictionary _fileWatchers = new(); + + /// + /// Exception counts to prevent infinite loops when saving files + /// + private readonly Dictionary _exceptCounts = new(); + + /// + /// Whether hot-reload is enabled + /// + public bool HotReloadEnabled { get; set; } = true; + /// /// Event raised when configuration changes /// @@ -59,6 +72,11 @@ public class ConfigManager : IConfigService /// public ISecurityConfig SecurityConfig { get; private set; } = new SecurityConfig(); + /// + /// Gets the typed security configuration (strong type version) + /// + public SecurityConfig TypedSecurityConfig => (SecurityConfig)SecurityConfig; + /// /// Private constructor /// @@ -94,6 +112,141 @@ public void Load() LoadConfigFile("AppConfig.json"); LoadConfigFile("PluginsConfig.json"); LoadConfigFile("SecurityConfig.json"); + + // Register file watchers for hot-reload if enabled + if (HotReloadEnabled) + { + RegisterFileWatcher("AppConfig.json"); + RegisterFileWatcher("PluginsConfig.json"); + RegisterFileWatcher("SecurityConfig.json"); + } + } + + /// + /// Registers a file watcher for a config file to enable hot-reload + /// + private void RegisterFileWatcher(string fileName) where T : class + { + var watcherName = $"ConfigFileWatcher_{typeof(T).Name}"; + var path = Path.Combine(_configLocation!, fileName); + + if (_fileWatchers.ContainsKey(watcherName)) + { + // Already registered + return; + } + + var directory = Path.GetDirectoryName(path); + var filter = Path.GetFileName(path); + + if (string.IsNullOrEmpty(directory)) + return; + + var watcher = new FileSystemWatcher(directory) + { + Filter = filter, + NotifyFilter = NotifyFilters.LastWrite, + EnableRaisingEvents = true + }; + + watcher.Changed += (sender, args) => + { + // Use ExceptCount to prevent infinite loops + if (_exceptCounts.TryGetValue(watcherName, out var count) && count > 0) + { + _exceptCounts[watcherName] = count - 1; + Log.Debug("FileWatcher {WatcherName}: Skipping change event (ExceptCount: {Count})", watcherName, _exceptCounts[watcherName]); + return; + } + + Log.Information("FileWatcher {WatcherName}: File changed - {FileName}, {ChangeType}", + watcherName, args.Name, args.ChangeType); + + try + { + // Reload the config file directly - read from file and update the stored config + ReloadConfigFile(fileName); + OnConfigChanged(typeof(T).Name, "FileChanged", null, null); + } + catch (Exception ex) + { + Log.Error(ex, "FileWatcher {WatcherName}: Error reloading config: {Message}", watcherName, ex.Message); + } + }; + + _fileWatchers[watcherName] = watcher; + _exceptCounts[watcherName] = 0; + + Log.Information("FileWatcher {WatcherName}: Registered for {Path}", watcherName, path); + } + + /// + /// Reloads a single config file from disk (used by file watcher) + /// + private void ReloadConfigFile(string fileName) where T : class + { + var path = Path.Combine(_configLocation!, fileName); + + if (!File.Exists(path)) + { + Log.Warning("Config file {FileName} not found for reload", fileName); + return; + } + + var json = File.ReadAllText(path); + object? config = null; + + // Special handling for SecurityConfig to deserialize correctly + if (typeof(T) == typeof(SecurityConfig)) + { + config = DeserializeSecurityConfig(json); + } + else + { + // Use ConfigBase for polymorphic deserialization + config = JsonSerializer.Deserialize(json, ConfigSerializationOptions.Options); + } + + if (config != null) + { + _configs[typeof(T).Name] = config; + + // Update the public properties + if (typeof(T) == typeof(AppConfig)) + AppConfig = config as IAppConfig ?? new AppConfig(); + else if (typeof(T) == typeof(PluginsConfig)) + PluginsConfig = config as IPluginsConfig ?? new PluginsConfig(); + else if (typeof(T) == typeof(SecurityConfig)) + SecurityConfig = config as ISecurityConfig ?? new SecurityConfig(); + + Log.Information("Reloaded config file {FileName}", fileName); + } + } + + /// + /// Increases the exception count to prevent file change events from triggering reloads + /// + public void IncreaseExceptCount(string watcherName, int count = 1) + { + if (_exceptCounts.TryGetValue(watcherName, out var current)) + { + _exceptCounts[watcherName] = current + count; + } + else + { + _exceptCounts[watcherName] = count; + } + } + + /// + /// Decreases the exception count + /// + public void DecreaseExceptCount(string watcherName, int count = 1) + { + if (_exceptCounts.TryGetValue(watcherName, out var current)) + { + _exceptCounts[watcherName] = Math.Max(0, current - count); + } } /// @@ -101,9 +254,82 @@ public void Load() /// public void SaveAll() { - SaveConfigFile(AppConfig, "AppConfig.json"); - SaveConfigFile(PluginsConfig, "PluginsConfig.json"); - SaveConfigFile(SecurityConfig, "SecurityConfig.json"); + SaveConfigFile((AppConfig)AppConfig, "AppConfig.json"); + SaveConfigFile((PluginsConfig)PluginsConfig, "PluginsConfig.json"); + SaveConfigFile((SecurityConfig)SecurityConfig, "SecurityConfig.json"); + } + + /// + /// Deserializes security config from JSON with proper structure + /// + private SecurityConfig DeserializeSecurityConfig(string json) + { + try + { + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + var config = new SecurityConfig(); + + // Parse metadata fields + if (root.TryGetProperty("ConfigFileLocation", out var configFileLocation)) + { + config.ConfigFileLocation = configFileLocation.GetString(); + } + if (root.TryGetProperty("ConfigFileWatcherName", out var configFileWatcherName)) + { + config.ConfigFileWatcherName = configFileWatcherName.GetString(); + } + if (root.TryGetProperty("ConfigGeneratedTime", out var configGeneratedTime)) + { + if (DateTime.TryParse(configGeneratedTime.GetString(), out var generatedTime)) + config.ConfigGeneratedTime = generatedTime; + } + + if (root.TryGetProperty("DeviceKeys", out var deviceKeysElement)) + { + var deviceKeys = new List(); + + foreach (var keyElement in deviceKeysElement.EnumerateArray()) + { + var impl = new DeviceKeyImpl(); + + // Parse Device object + if (keyElement.TryGetProperty("Device", out var deviceElement)) + { + impl.Device = new DeviceLocator + { + DeviceName = deviceElement.TryGetProperty("DeviceName", out var dn) ? dn.GetString() ?? "" : "", + IPv4 = deviceElement.TryGetProperty("IPv4", out var ipv4) ? ipv4.GetString() ?? "" : "", + IPv6 = deviceElement.TryGetProperty("IPv6", out var ipv6) ? ipv6.GetString() ?? "" : "", + MacAddress = deviceElement.TryGetProperty("MacAddress", out var mac) ? mac.GetString() ?? "" : "" + }; + } + + // Parse RSA keys + impl.RsaPublicKeyPem = keyElement.TryGetProperty("RsaPublicKeyPem", out var pubKey) ? pubKey.GetString() : null; + impl.RsaPrivateKeyPem = keyElement.TryGetProperty("RsaPrivateKeyPem", out var privKey) ? privKey.GetString() : null; + + // Parse AddedAt + if (keyElement.TryGetProperty("AddedAt", out var addedAtElement)) + { + if (DateTime.TryParse(addedAtElement.GetString(), out var addedAt)) + impl.AddedAt = addedAt; + } + + deviceKeys.Add(impl); + } + + config.DeviceKeys = deviceKeys; + } + + return config; + } + catch (Exception ex) + { + Log.Error(ex, "Error deserializing SecurityConfig: {Message}", ex.Message); + return new SecurityConfig(); + } } /// @@ -123,7 +349,18 @@ public void Reload() if (File.Exists(path)) { var json = File.ReadAllText(path); - var config = JsonSerializer.Deserialize(json); + object? config = null; + + // Special handling for SecurityConfig to deserialize correctly + if (typeof(T) == typeof(SecurityConfig)) + { + config = DeserializeSecurityConfig(json); + } + else + { + // Use ConfigBase for polymorphic deserialization + config = JsonSerializer.Deserialize(json, ConfigSerializationOptions.Options); + } if (config != null) { @@ -162,19 +399,30 @@ public void Reload() } } - private void SaveConfigFile(T config, string fileName) + /// + /// Saves config file + /// + private void SaveConfigFile(T config, string fileName) where T : class { try { var path = Path.Combine(_configLocation!, fileName); - var options = new JsonSerializerOptions + // Increase ExceptCount to prevent file change event from triggering reload + var watcherName = $"ConfigFileWatcher_{typeof(T).Name}"; + IncreaseExceptCount(watcherName, 2); + + // Update metadata fields before serialization + if (config is IConfigWithMetadata metadata) { - WriteIndented = true - }; + metadata.ConfigFileLocation = path; + metadata.ConfigFileWatcherName = watcherName; + metadata.ConfigGeneratedTime = DateTime.Now; + } - var json = JsonSerializer.Serialize(config, options); - File.WriteAllText(path, json); + // Serialize the config object directly + var jsonContent = JsonSerializer.Serialize(config, ConfigSerializationOptions.Options); + File.WriteAllText(path, jsonContent); } catch (Exception ex) { @@ -199,42 +447,21 @@ protected void OnConfigChanged(string configType, string propertyName, object? o NewValue = newValue }); } -} - -public class AnnouncementConfig : IAnnouncementConfig -{ - public List Accepted { get; set; } = []; - - /// - /// Configuration file location (for backward compatibility) - /// - public string? ConfigFileLocation { get; set; } /// - /// Saves the configuration to file (for backward compatibility) + /// Disposes the configuration manager and releases all resources /// - /// File path to save - /// This instance - public AnnouncementConfig Save(string path) + public void Dispose() { - // Implementation would save to file - simplified for compatibility - return this; - } -} - - -/// -/// Plugins configuration implementation -/// -public class PluginsConfig : IPluginsConfig -{ - public IList Plugins { get; set; } = []; -} + // Dispose all file watchers + foreach (var watcher in _fileWatchers.Values) + { + watcher.EnableRaisingEvents = false; + watcher.Dispose(); + } + _fileWatchers.Clear(); + _exceptCounts.Clear(); -/// -/// Security configuration implementation -/// -public class SecurityConfig : ISecurityConfig -{ - public IDictionary DeviceKeys { get; set; } = new Dictionary(); + Log.Information("ConfigManager disposed"); + } } diff --git a/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigSerializationOptions.cs b/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigSerializationOptions.cs new file mode 100644 index 00000000..6adacd60 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigSerializationOptions.cs @@ -0,0 +1,16 @@ +using System.Text.Json; + +namespace KitX.Core.Configuration; + +/// +/// JSON serializer options for configuration files +/// +internal static class ConfigSerializationOptions +{ + internal static readonly JsonSerializerOptions Options = new() + { + WriteIndented = true, + IncludeFields = true, + PropertyNameCaseInsensitive = true + }; +} diff --git a/KitX Clients/KitX Core/KitX.Core/Configuration/DeviceKeyImpl.cs b/KitX Clients/KitX Core/KitX.Core/Configuration/DeviceKeyImpl.cs new file mode 100644 index 00000000..ec08d922 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Configuration/DeviceKeyImpl.cs @@ -0,0 +1,42 @@ +using System; +using System.Text.Json.Serialization; +using KitX.Core.Contract.Configuration; +using KitX.Shared.CSharp.Device; + +namespace KitX.Core.Configuration; + +/// +/// Device key implementation for JSON serialization +/// +public class DeviceKeyImpl : IDeviceKey +{ + /// + /// Device locator + /// + [JsonPropertyName("Device")] + public DeviceLocator Device { get; set; } = new(); + + /// + /// RSA public key in PEM format + /// + [JsonPropertyName("RsaPublicKeyPem")] + public string? RsaPublicKeyPem { get; set; } + + /// + /// RSA private key in PEM format + /// + [JsonPropertyName("RsaPrivateKeyPem")] + public string? RsaPrivateKeyPem { get; set; } + + /// + /// Time when this key was added + /// + [JsonPropertyName("AddedAt")] + public DateTime AddedAt { get; set; } = DateTime.Now; + + // Explicit interface implementations - these won't be serialized since they use different names + string IDeviceKey.MacAddress => Device?.MacAddress ?? string.Empty; + string IDeviceKey.DeviceName => Device?.DeviceName ?? string.Empty; + string IDeviceKey.PublicKey => RsaPublicKeyPem ?? string.Empty; + DateTime IDeviceKey.AddedAt => AddedAt; +} diff --git a/KitX Clients/KitX Core/KitX.Core/Configuration/PluginsConfig.cs b/KitX Clients/KitX Core/KitX.Core/Configuration/PluginsConfig.cs new file mode 100644 index 00000000..4ef8aa0a --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Configuration/PluginsConfig.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json.Serialization; +using KitX.Core.Contract.Configuration; +using KitX.Core.Plugin; +using KitX.Shared.CSharp.Plugin; + +namespace KitX.Core.Configuration; + +/// +/// Plugins configuration implementation +/// +public class PluginsConfig : IPluginsConfig, IConfigWithMetadata +{ + /// + /// Configuration file location + /// + public string? ConfigFileLocation { get; set; } + + /// + /// Configuration file watcher name + /// + public string? ConfigFileWatcherName { get; set; } + + /// + /// Configuration generated time + /// + public DateTime? ConfigGeneratedTime { get; set; } = DateTime.Now; + + /// + /// Plugins list (concrete type for proper serialization) + /// + public List Plugins { get; set; } = []; + + /// + /// Gets plugins as interface (for external use) + /// + [JsonIgnore] + IList IPluginsConfig.Plugins + { + get => Plugins.Cast().ToList(); + set => Plugins = value?.Cast().ToList() ?? []; + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Configuration/SecurityConfig.cs b/KitX Clients/KitX Core/KitX.Core/Configuration/SecurityConfig.cs new file mode 100644 index 00000000..03eda939 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Configuration/SecurityConfig.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json.Serialization; +using KitX.Core.Contract.Configuration; + +namespace KitX.Core.Configuration; + +/// +/// Security configuration implementation +/// +public class SecurityConfig : ISecurityConfig, IConfigWithMetadata +{ + /// + /// Configuration file location + /// + public string? ConfigFileLocation { get; set; } + + /// + /// Configuration file watcher name + /// + public string? ConfigFileWatcherName { get; set; } + + /// + /// Configuration generated time + /// + public DateTime? ConfigGeneratedTime { get; set; } = DateTime.Now; + + /// + /// Gets or sets the device keys list (concrete type for proper serialization) + /// + public List DeviceKeys { get; set; } = []; + + /// + /// Gets the device keys as interface (for external use) + /// + [JsonIgnore] + IList ISecurityConfig.DeviceKeys + { + get => DeviceKeys.Cast().ToList(); + set => DeviceKeys = value?.Cast().ToList() ?? []; + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs b/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs index a3fd4798..ca02865d 100644 --- a/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs +++ b/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs @@ -140,7 +140,7 @@ public static IServiceCollection AddCoreServices(this IServiceCollection service }); Log.Information("Registering IPluginServer..."); - services.AddSingleton(provider => + services.AddSingleton(provider => { var service = PluginsServer.Instance; return service; @@ -150,7 +150,7 @@ public static IServiceCollection AddCoreServices(this IServiceCollection service Log.Information("Registering IAnnouncementService..."); services.AddSingleton(provider => { - var configService = provider.GetService(); + var configService = provider.GetService(); var service = configService != null ? new AnnouncementManager(configService) : AnnouncementManager.Instance; diff --git a/KitX Clients/KitX Core/KitX.Core/Device/DevicesServer.cs b/KitX Clients/KitX Core/KitX.Core/Device/DevicesServer.cs index 8051bc33..04af613b 100644 --- a/KitX Clients/KitX Core/KitX.Core/Device/DevicesServer.cs +++ b/KitX Clients/KitX Core/KitX.Core/Device/DevicesServer.cs @@ -1,10 +1,14 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; +using System.Text; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using KitX.Core.Contract.Device; using KitX.Core.Event; +using KitX.Core.Security; using KitX.Shared.CSharp.Device; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; @@ -34,6 +38,16 @@ public class DevicesServer : IDeviceServer private IWebHost? _host; private int? _configuredPort; + /// + /// Whether device key exchange is in progress + /// + private bool _isExchangingDeviceKey = false; + + /// + /// Device key exchange verification code + /// + private string? _exchangeDeviceKeyCode; + /// /// Gets the service status /// @@ -96,11 +110,39 @@ public IDeviceServer Run() await context.Response.WriteAsync("KitX DevicesServer is running"); }); - // Device authentication endpoint (placeholder for future implementation) - endpoints.MapPost("/api/device/auth", async context => + // Device controller endpoints (旧架构 API 标准) + // GET /Api/V1/Device?token=xxx + // POST /Api/V1/Device/ExchangeKey?verifyCodeSHA1=xxx&address=xxx + // POST /Api/V1/Device/ExchangeKeyBack + // POST /Api/V1/Device/CancelExchangingKey + // POST /Api/V1/Device/Connect?deviceBase64=xxx + endpoints.MapGet("/Api/V1/Device", async context => { - // TODO: Implement device authentication logic - await context.Response.WriteAsync("{\"status\":\"pending\"}"); + await HandleGetDeviceInfoAsync(context); + }); + + endpoints.MapPost("/Api/V1/Device/{action}", async context => + { + var action = context.Request.RouteValues["action"]?.ToString(); + switch (action) + { + case "ExchangeKey": + await HandleExchangeKeyAsync(context); + break; + case "ExchangeKeyBack": + await HandleExchangeKeyBackAsync(context); + break; + case "CancelExchangingKey": + await HandleCancelExchangingKeyAsync(context); + break; + case "Connect": + await HandleConnectAsync(context); + break; + default: + context.Response.StatusCode = 404; + await context.Response.WriteAsync("Not found"); + break; + } }); }); }) @@ -259,4 +301,467 @@ public string SignInDevice(DeviceLocator locator) return token; } + + /// + /// Handles GetDeviceInfo request (旧架构 API) + /// GET /Api/V1/Device?token=xxx + /// + private async System.Threading.Tasks.Task HandleGetDeviceInfoAsync(HttpContext context) + { + var token = context.Request.Query["token"].ToString(); + if (string.IsNullOrEmpty(token)) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync("Missing token parameter"); + return; + } + + if (IsDeviceTokenExist(token)) + { + var deviceInfo = DevicesDiscoveryServer.Instance?.DefaultDeviceInfo; + if (deviceInfo != null) + { + context.Response.ContentType = "application/json"; + await context.Response.WriteAsync(JsonSerializer.Serialize(deviceInfo)); + } + else + { + context.Response.StatusCode = 500; + await context.Response.WriteAsync("Device info not available"); + } + } + else + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync("You should connect to this device first."); + } + } + + /// + /// Handles ExchangeKey request (旧架构 API) + /// POST /Api/V1/Device/ExchangeKey?verifyCodeSHA1=xxx&address=xxx + /// + private async System.Threading.Tasks.Task HandleExchangeKeyAsync(HttpContext context) + { + try + { + if (_isExchangingDeviceKey) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync("Remote device is exchanging device key."); + return; + } + + var securityService = SecurityManager.Instance; + if (securityService.LocalDeviceKey == null) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync("Remote device didn't set up device key."); + return; + } + + // Read request body + using var reader = new StreamReader(context.Request.Body); + var body = await reader.ReadToEndAsync(); + var request = JsonSerializer.Deserialize(body); + + if (request == null) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync("Invalid request"); + return; + } + + // Generate verification code + _exchangeDeviceKeyCode = Guid.NewGuid().ToString("N")[..8]; + _isExchangingDeviceKey = true; + + // Publish event for UI to handle + EventService.Instance.Publish(EventNames.OnReceiveCancelExchangingDeviceKey, EventArgs.Empty); + + // For now, auto-accept the key exchange (in real implementation, this would show a UI) + // TODO: Integrate with UI for verification code input + var deviceKeyDecrypted = securityService.AesDecrypt(request.DeviceKey, _exchangeDeviceKeyCode); + var deviceKeyInstance = JsonSerializer.Deserialize(deviceKeyDecrypted); + + if (deviceKeyInstance == null) + { + _isExchangingDeviceKey = false; + _exchangeDeviceKeyCode = null; + context.Response.StatusCode = 400; + await context.Response.WriteAsync("Failed to decrypt device key"); + return; + } + + // Add device key + securityService.AddDeviceKey( + deviceKeyInstance.Device.MacAddress, + deviceKeyInstance.Device.DeviceName, + deviceKeyInstance.RsaPublicKeyPem ?? "" + ); + + // Send back local key + var currentKey = securityService.GetPrivateDeviceKey(); + if (currentKey == null) + { + _isExchangingDeviceKey = false; + _exchangeDeviceKeyCode = null; + context.Response.StatusCode = 500; + await context.Response.WriteAsync("Failed to get local key"); + return; + } + + var currentKeyJson = JsonSerializer.Serialize(currentKey); + var currentKeyEncrypted = securityService.AesEncrypt(currentKeyJson, _exchangeDeviceKeyCode!); + + _isExchangingDeviceKey = false; + _exchangeDeviceKeyCode = null; + + // Publish accept event + EventService.Instance.Publish(EventNames.OnAcceptingDeviceKey, EventArgs.Empty); + + context.Response.ContentType = "application/json"; + await context.Response.WriteAsync(JsonSerializer.Serialize(currentKeyEncrypted)); + } + catch (Exception ex) + { + Log.Error(ex, "Error in HandleExchangeKeyAsync"); + _isExchangingDeviceKey = false; + _exchangeDeviceKeyCode = null; + context.Response.StatusCode = 500; + await context.Response.WriteAsync($"Error: {ex.Message}"); + } + } + + /// + /// Handles device key exchange back response + /// + private async System.Threading.Tasks.Task HandleExchangeKeyBackAsync(HttpContext context) + { + try + { + if (_exchangeDeviceKeyCode == null) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync("No pending key exchange"); + return; + } + + // Read request body + using var reader = new StreamReader(context.Request.Body); + var encryptedKey = await reader.ReadToEndAsync(); + + var securityService = SecurityManager.Instance; + var deviceKeyDecrypted = securityService.AesDecrypt(encryptedKey, _exchangeDeviceKeyCode); + var deviceKeyInstance = JsonSerializer.Deserialize(deviceKeyDecrypted); + + if (deviceKeyInstance == null) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync("Failed to decrypt device key"); + return; + } + + // Add device key + securityService.AddDeviceKey( + deviceKeyInstance.Device.MacAddress, + deviceKeyInstance.Device.DeviceName, + deviceKeyInstance.RsaPublicKeyPem ?? "" + ); + + _exchangeDeviceKeyCode = null; + + // Publish accept event + EventService.Instance.Publish(EventNames.OnAcceptingDeviceKey, EventArgs.Empty); + + context.Response.StatusCode = 200; + await context.Response.WriteAsync("OK"); + } + catch (Exception ex) + { + Log.Error(ex, "Error in HandleExchangeKeyBackAsync"); + context.Response.StatusCode = 500; + await context.Response.WriteAsync($"Error: {ex.Message}"); + } + } + + /// + /// Handles CancelExchangingKey request (旧架构 API) + /// POST /Api/V1/Device/CancelExchangingKey + /// + private async System.Threading.Tasks.Task HandleCancelExchangingKeyAsync(HttpContext context) + { + if (_isExchangingDeviceKey == false) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync("Remote device isn't exchanging device key."); + return; + } + + EventService.Instance.Publish(EventNames.OnReceiveCancelExchangingDeviceKey, EventArgs.Empty); + + _isExchangingDeviceKey = false; + _exchangeDeviceKeyCode = null; + + context.Response.StatusCode = 200; + await context.Response.WriteAsync("OK"); + } + + /// + /// Handles Connect request (旧架构 API) + /// POST /Api/V1/Device/Connect?deviceBase64=xxx + /// + private async System.Threading.Tasks.Task HandleConnectAsync(HttpContext context) + { + try + { + var deviceBase64 = context.Request.Query["deviceBase64"].ToString(); + if (string.IsNullOrEmpty(deviceBase64)) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync($"Missing deviceBase64 parameter"); + return; + } + + // Read request body (encrypted device name) + using var reader = new StreamReader(context.Request.Body); + var deviceNameEncrypted = await reader.ReadToEndAsync(); + + // Decode device locator + var deviceBytes = Convert.FromBase64String(deviceBase64); + var deviceJson = Encoding.UTF8.GetString(deviceBytes); + var device = JsonSerializer.Deserialize(deviceJson); + + if (device == null) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync($"Invalid deviceBase64 parameter"); + return; + } + + // Search for device key + var securityService = SecurityManager.Instance; + var key = securityService.SearchDeviceKey(device); + + if (key == null) + { + context.Response.StatusCode = 401; + await context.Response.WriteAsync("You are not authorized by remote device."); + return; + } + + // Decrypt and verify device name + var deviceNameDecrypted = securityService.RsaDecryptString(key, deviceNameEncrypted); + + if (deviceNameDecrypted == null) + { + context.Response.StatusCode = 500; + await context.Response.WriteAsync("Remote crashed when decrypting device name."); + return; + } + + if (!device.DeviceName.Equals(deviceNameDecrypted)) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync("You provided incorrect encrypted device name."); + return; + } + + // Sign in device + var token = SignInDevice(device); + + // Encrypt token with local private key + var encryptedToken = securityService.EncryptStringAsync(token, "").Result; + + context.Response.ContentType = "application/json"; + await context.Response.WriteAsync(encryptedToken); + } + catch (Exception ex) + { + Log.Error(ex, "Error in HandleConnectAsync"); + context.Response.StatusCode = 500; + await context.Response.WriteAsync($"Error: {ex.Message}"); + } + } + + /// + /// Handles legacy ExchangeKey request (旧架构兼容) + /// + private async System.Threading.Tasks.Task HandleExchangeKeyLegacyAsync(HttpContext context) + { + try + { + if (_isExchangingDeviceKey) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync("Remote device is exchanging device key."); + return; + } + + var securityService = SecurityManager.Instance; + if (securityService.LocalDeviceKey == null) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync("Remote device didn't set up device key."); + return; + } + + // Read query parameters (legacy format) + var verifyCodeSHA1 = context.Request.Query["verifyCodeSHA1"].ToString(); + var address = context.Request.Query["address"].ToString(); + + // Read body (device key as plain string) + using var reader = new StreamReader(context.Request.Body); + var deviceKey = await reader.ReadToEndAsync(); + + // Generate verification code + _exchangeDeviceKeyCode = Guid.NewGuid().ToString("N")[..8]; + _isExchangingDeviceKey = true; + + // Auto-accept for now (legacy would show UI) + var deviceKeyDecrypted = securityService.AesDecrypt(deviceKey, _exchangeDeviceKeyCode); + var deviceKeyInstance = JsonSerializer.Deserialize(deviceKeyDecrypted); + + if (deviceKeyInstance == null) + { + _isExchangingDeviceKey = false; + _exchangeDeviceKeyCode = null; + context.Response.StatusCode = 400; + await context.Response.WriteAsync("Failed to decrypt device key"); + return; + } + + // Add device key + securityService.AddDeviceKey( + deviceKeyInstance.Device.MacAddress, + deviceKeyInstance.Device.DeviceName, + deviceKeyInstance.RsaPublicKeyPem ?? "" + ); + + // Send back local key + var currentKey = securityService.GetPrivateDeviceKey(); + if (currentKey == null) + { + _isExchangingDeviceKey = false; + _exchangeDeviceKeyCode = null; + context.Response.StatusCode = 500; + await context.Response.WriteAsync("Failed to get local key"); + return; + } + + var currentKeyJson = JsonSerializer.Serialize(currentKey); + var currentKeyEncrypted = securityService.AesEncrypt(currentKeyJson, _exchangeDeviceKeyCode); + + _isExchangingDeviceKey = false; + _exchangeDeviceKeyCode = null; + + // Return as plain string (legacy format) + await context.Response.WriteAsync(currentKeyEncrypted); + } + catch (Exception ex) + { + Log.Error(ex, "Error in HandleExchangeKeyLegacyAsync"); + _isExchangingDeviceKey = false; + _exchangeDeviceKeyCode = null; + context.Response.StatusCode = 500; + await context.Response.WriteAsync($"Error: {ex.Message}"); + } + } + + /// + /// Handles legacy Connect request (旧架构兼容) + /// + private async System.Threading.Tasks.Task HandleConnectLegacyAsync(HttpContext context) + { + try + { + var deviceBase64 = context.Request.Query["deviceBase64"].ToString(); + if (string.IsNullOrEmpty(deviceBase64)) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync($"Missing deviceBase64 parameter"); + return; + } + + // Read body (encrypted device name as plain string) + using var reader = new StreamReader(context.Request.Body); + var deviceNameEncrypted = await reader.ReadToEndAsync(); + + // Decode device locator + var deviceBytes = Convert.FromBase64String(deviceBase64); + var deviceJson = Encoding.UTF8.GetString(deviceBytes); + var device = JsonSerializer.Deserialize(deviceJson); + + if (device == null) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync($"Invalid deviceBase64 parameter"); + return; + } + + // Search for device key + var securityService = SecurityManager.Instance; + var key = securityService.SearchDeviceKey(device); + + if (key == null) + { + context.Response.StatusCode = 401; + await context.Response.WriteAsync("You are not authorized by remote device."); + return; + } + + // Decrypt and verify device name + var deviceNameDecrypted = securityService.RsaDecryptString(key, deviceNameEncrypted); + + if (deviceNameDecrypted == null) + { + context.Response.StatusCode = 500; + await context.Response.WriteAsync("Remote crashed when decrypting device name."); + return; + } + + if (!device.DeviceName.Equals(deviceNameDecrypted)) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync("You provided incorrect encrypted device name."); + return; + } + + // Sign in device + var token = SignInDevice(device); + + // Encrypt token (legacy uses EncryptString) + var encryptedToken = securityService.EncryptStringAsync(token, "").Result; + + context.Response.ContentType = "application/json"; + await context.Response.WriteAsync(encryptedToken); + } + catch (Exception ex) + { + Log.Error(ex, "Error in HandleConnectLegacyAsync"); + context.Response.StatusCode = 500; + await context.Response.WriteAsync($"Error: {ex.Message}"); + } + } +} + +/// +/// Exchange key request model +/// +public class ExchangeKeyRequest +{ + /// + /// AES encrypted device key + /// + public string? DeviceKey { get; set; } + + /// + /// Address of requesting device + /// + public string? Address { get; set; } + + /// + /// SHA1 of verification code + /// + public string? VerifyCodeSHA1 { get; set; } } diff --git a/KitX Clients/KitX Core/KitX.Core/Device/PluginsServer.cs b/KitX Clients/KitX Core/KitX.Core/Device/PluginsServer.cs index 20c43332..42984a6a 100644 --- a/KitX Clients/KitX Core/KitX.Core/Device/PluginsServer.cs +++ b/KitX Clients/KitX Core/KitX.Core/Device/PluginsServer.cs @@ -191,10 +191,10 @@ public IPluginServer Run() { try { - var kwc = System.Text.Json.JsonSerializer.Deserialize(message); + var kwc = System.Text.Json.JsonSerializer.Deserialize(message); if (kwc?.Content is not null) { - var cmd = System.Text.Json.JsonSerializer.Deserialize(kwc.Content); + var cmd = System.Text.Json.JsonSerializer.Deserialize(kwc.Content); if (cmd.Request == KitX.Shared.CSharp.WebCommand.Infos.CommandRequestInfo.RegisterPlugin) { var body = System.Text.Encoding.UTF8.GetString(cmd.Body.AsSpan(0, cmd.BodyLength).ToArray()); diff --git a/KitX Clients/KitX Core/KitX.Core/Event/EventArgs.cs b/KitX Clients/KitX Core/KitX.Core/Event/EventArgs.cs index 14176dd4..d855cdc6 100644 --- a/KitX Clients/KitX Core/KitX.Core/Event/EventArgs.cs +++ b/KitX Clients/KitX Core/KitX.Core/Event/EventArgs.cs @@ -33,7 +33,7 @@ public class DeviceInfoEventArgs : EventArgs /// /// The device information /// - public KitX.Shared.CSharp.Device.DeviceInfo? DeviceInfo { get; set; } + public Shared.CSharp.Device.DeviceInfo? DeviceInfo { get; set; } } /// diff --git a/KitX Clients/KitX Core/KitX.Core/Plugin/PluginsManager.cs b/KitX Clients/KitX Core/KitX.Core/Plugin/PluginsManager.cs index 82b0dc72..9eb8490a 100644 --- a/KitX Clients/KitX Core/KitX.Core/Plugin/PluginsManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/Plugin/PluginsManager.cs @@ -226,7 +226,7 @@ public async Task ImportPluginAsync(string kxpFilePath) // Decode the KXP file try { - var decoder = new KitX.FileFormats.CSharp.ExtensionsPackage.Decoder(kxpFilePath); + var decoder = new FileFormats.CSharp.ExtensionsPackage.Decoder(kxpFilePath); decoder.Decode(pluginDir); Log.Information($"Decoded KXP file to: {pluginDir}"); } @@ -254,8 +254,8 @@ public async Task ImportPluginAsync(string kxpFilePath) var loaderStructPath = Path.Combine(pluginDir, "LoaderStruct.json"); var pluginStructPath = Path.Combine(pluginDir, "PluginStruct.json"); - KitX.Shared.CSharp.Plugin.PluginInfo? pluginInfo = null; - KitX.Shared.CSharp.Loader.LoaderInfo? loaderInfo = null; + PluginInfo? pluginInfo = null; + LoaderInfo? loaderInfo = null; // Parse LoaderStruct.json if exists if (File.Exists(loaderStructPath)) @@ -263,9 +263,9 @@ public async Task ImportPluginAsync(string kxpFilePath) try { var loaderStructJson = await File.ReadAllTextAsync(loaderStructPath); - var loaderStruct = System.Text.Json.JsonSerializer.Deserialize(loaderStructJson); + var loaderStruct = System.Text.Json.JsonSerializer.Deserialize(loaderStructJson); - loaderInfo = new KitX.Shared.CSharp.Loader.LoaderInfo + loaderInfo = new LoaderInfo { LoaderName = loaderStruct.TryGetProperty("LoaderName", out var name) ? name.GetString() : "Unknown", LoaderVersion = loaderStruct.TryGetProperty("LoaderVersion", out var version) ? version.GetString() : "1.0.0", @@ -276,7 +276,7 @@ public async Task ImportPluginAsync(string kxpFilePath) }; // Copy loader struct to LoaderInfo.json (different format) - var loaderInfoJson = System.Text.Json.JsonSerializer.Serialize(loaderInfo, new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); + var loaderInfoJson = System.Text.Json.JsonSerializer.Serialize(loaderInfo, new JsonSerializerOptions { WriteIndented = true }); await File.WriteAllTextAsync(Path.Combine(pluginDir, "LoaderInfo.json"), loaderInfoJson); Log.Information($"Parsed LoaderStruct: {loaderInfo.LoaderName} v{loaderInfo.LoaderVersion}"); @@ -293,9 +293,9 @@ public async Task ImportPluginAsync(string kxpFilePath) try { var pluginStructJson = await File.ReadAllTextAsync(pluginStructPath); - var pluginStruct = System.Text.Json.JsonSerializer.Deserialize(pluginStructJson); + var pluginStruct = System.Text.Json.JsonSerializer.Deserialize(pluginStructJson); - pluginInfo = new KitX.Shared.CSharp.Plugin.PluginInfo + pluginInfo = new PluginInfo { Name = pluginStruct.TryGetProperty("Name", out var name) ? name.GetString() ?? "Unknown" : "Unknown", Version = pluginStruct.TryGetProperty("Version", out var ver) ? ver.GetString() ?? "1.0.0" : "1.0.0", @@ -304,7 +304,7 @@ public async Task ImportPluginAsync(string kxpFilePath) ComplexDescription = new Dictionary(), TotalDescriptionInMarkdown = new Dictionary(), Tags = new Dictionary(), - Functions = new List() + Functions = new List() }; // Parse DisplayName @@ -366,7 +366,7 @@ public async Task ImportPluginAsync(string kxpFilePath) // If no plugin info from KXP, create basic info from filename if (pluginInfo == null) { - pluginInfo = new KitX.Shared.CSharp.Plugin.PluginInfo + pluginInfo = new PluginInfo { Name = pluginFileName, Version = "1.0.0", @@ -377,7 +377,7 @@ public async Task ImportPluginAsync(string kxpFilePath) ComplexDescription = new Dictionary { { "en-us", "Imported plugin" } }, TotalDescriptionInMarkdown = new Dictionary(), Tags = new Dictionary(), - Functions = new List() + Functions = new List() }; } @@ -398,7 +398,7 @@ public async Task ImportPluginAsync(string kxpFilePath) } // Create PluginInfo.json - var pluginInfoJson = System.Text.Json.JsonSerializer.Serialize(pluginInfo, new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); + var pluginInfoJson = System.Text.Json.JsonSerializer.Serialize(pluginInfo, new JsonSerializerOptions { WriteIndented = true }); await File.WriteAllTextAsync(Path.Combine(pluginDir, "PluginInfo.json"), pluginInfoJson); // Create installation record @@ -407,8 +407,8 @@ public async Task ImportPluginAsync(string kxpFilePath) Id = GeneratePluginId(pluginInfo), InstallPath = pluginDir, PluginInfo = pluginInfo, - LoaderInfo = loaderInfo ?? new KitX.Shared.CSharp.Loader.LoaderInfo(), - InstalledDevices = new List() + LoaderInfo = loaderInfo ?? new LoaderInfo(), + InstalledDevices = new List() }; _plugins.Add(installation); diff --git a/KitX Clients/KitX Core/KitX.Core/Security/SecurityManager.cs b/KitX Clients/KitX Core/KitX.Core/Security/SecurityManager.cs index 7b6c3f5b..84b1d1e5 100644 --- a/KitX Clients/KitX Core/KitX.Core/Security/SecurityManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/Security/SecurityManager.cs @@ -1,9 +1,11 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Security.Cryptography; using System.Text; using KitX.Core.Contract.Security; +using KitX.Core.Configuration; +using KitX.Core.Device; using KitX.Shared.CSharp.Device; using KitX.Shared.CSharp.Security; using Serilog; @@ -26,6 +28,16 @@ public class SecurityManager : ISecurityService private DeviceKey? _localDeviceKey; + /// + /// Reference to ConfigManager instance + /// + private ConfigManager? _configManager; + + /// + /// Gets typed SecurityConfig for direct property access + /// + private SecurityConfig? TypedSecurityConfig => _configManager?.TypedSecurityConfig; + /// /// Gets the local device key /// @@ -48,54 +60,120 @@ private SecurityManager() /// private void Initialize() { + // Get ConfigManager instance + _configManager = ConfigManager.Instance; + // Create RSA instance _rsaInstance = RSA.Create(2048); - // Note: In a real implementation, you would load the device key from config - // For now, we'll create a new one if it doesn't exist - if (_localDeviceKey == null) + // Get current device info from DevicesDiscoveryServer (same as legacy architecture) + var defaultDeviceInfo = DevicesDiscoveryServer.Instance?.DefaultDeviceInfo; + var currentDevice = defaultDeviceInfo?.Device ?? new DeviceLocator { - GenerateLocalDeviceKey(); - } - else + DeviceName = Environment.MachineName, + MacAddress = GetMacAddress() + }; + + Log.Information("Initializing SecurityManager with device: {DeviceName}, {MacAddress}", + currentDevice.DeviceName, currentDevice.MacAddress); + + // Get typed SecurityConfig + var typedSecurityConfig = _configManager.SecurityConfig as SecurityConfig; + + // Try to find existing device key in SecurityConfig + var existingKey = typedSecurityConfig?.DeviceKeys + .FirstOrDefault(x => x.Device.IsSameDevice(currentDevice)); + + if (existingKey != null) { - // Load existing key - if (_localDeviceKey.RsaPublicKeyPem != null && _localDeviceKey.RsaPrivateKeyPem != null) + // Found existing key + _localDeviceKey = new DeviceKey + { + Device = existingKey.Device, + RsaPublicKeyPem = existingKey.RsaPublicKeyPem, + RsaPrivateKeyPem = existingKey.RsaPrivateKeyPem + }; + + // Load RSA instance with existing keys + if (!string.IsNullOrEmpty(_localDeviceKey.RsaPublicKeyPem) && + !string.IsNullOrEmpty(_localDeviceKey.RsaPrivateKeyPem)) { _rsaInstance.ImportFromPem(_localDeviceKey.RsaPublicKeyPem); _rsaInstance.ImportFromPem(_localDeviceKey.RsaPrivateKeyPem); + Log.Information("Loaded existing local device key from config"); } + else + { + // Keys incomplete, regenerate + Log.Warning("Existing device key incomplete, regenerating..."); + GenerateLocalDeviceKey(); + } + } + else + { + // No existing key, generate new one + Log.Information("No existing device key found, generating new one..."); + GenerateLocalDeviceKey(); } } private void GenerateLocalDeviceKey() { + // Get current network addresses + var ipv4 = NetworkHelper.GetInterNetworkIPv4(); + var ipv6 = NetworkHelper.GetInterNetworkIPv6(); + + Log.Information("Generating local device key. IPv4: {IPv4}, IPv6: {IPv6}", ipv4, ipv6); + var device = new DeviceLocator { DeviceName = Environment.MachineName, - MacAddress = GetMacAddress() + MacAddress = GetMacAddress(), + IPv4 = ipv4 ?? "", + IPv6 = ipv6 ?? "" }; + Log.Information("Device created. DeviceName: {DeviceName}, MacAddress: {MacAddress}, IPv4: {IPv4}, IPv6: {IPv6}", + device.DeviceName, device.MacAddress, device.IPv4, device.IPv6); + _localDeviceKey = new DeviceKey { Device = device, RsaPublicKeyPem = _rsaInstance!.ExportRSAPublicKeyPem(), RsaPrivateKeyPem = _rsaInstance.ExportRSAPrivateKeyPem() }; + + // Add to SecurityConfig and save + var deviceKeyImpl = new DeviceKeyImpl + { + Device = device, + RsaPublicKeyPem = _localDeviceKey.RsaPublicKeyPem, + RsaPrivateKeyPem = _localDeviceKey.RsaPrivateKeyPem, + AddedAt = DateTime.Now + }; + + _configManager?.TypedSecurityConfig?.DeviceKeys.Add(deviceKeyImpl); + _configManager?.SaveAll(); + + Log.Information($"Generated and saved new local device key. Keys count: {_configManager?.TypedSecurityConfig?.DeviceKeys.Count}"); } /// - /// Gets all device keys + /// Gets all device keys from SecurityConfig /// /// List of device keys - public IReadOnlyList GetDeviceKeys() + public IReadOnlyList GetDeviceKeys() { - // This would typically come from SecurityConfig - return new List(); + var keys = _configManager?.TypedSecurityConfig?.DeviceKeys; + if (keys == null) + return new List(); + + // Return the device keys directly + return keys.ToList(); } /// - /// Adds a device key + /// Adds a device key to SecurityConfig /// /// The MAC address /// The device name @@ -105,17 +183,20 @@ public bool AddDeviceKey(string macAddress, string deviceName, string publicKey) { try { - var deviceKey = new DeviceKey + var deviceKey = new DeviceKeyImpl { Device = new DeviceLocator { MacAddress = macAddress, DeviceName = deviceName }, - RsaPublicKeyPem = publicKey + RsaPublicKeyPem = publicKey, + AddedAt = DateTime.Now }; - // In a real implementation, you would save this to SecurityConfig + _configManager?.TypedSecurityConfig?.DeviceKeys.Add(deviceKey); + _configManager?.SaveAll(); + Log.Information($"Added device key for {deviceName} ({macAddress})"); return true; @@ -128,7 +209,7 @@ public bool AddDeviceKey(string macAddress, string deviceName, string publicKey) } /// - /// Removes a device key + /// Removes a device key from SecurityConfig /// /// The MAC address /// True if successful @@ -136,7 +217,20 @@ public bool RemoveDeviceKey(string macAddress) { try { - // In a real implementation, you would remove this from SecurityConfig + var typedSecurityConfig = _configManager?.SecurityConfig as SecurityConfig; + var keysToRemove = typedSecurityConfig?.DeviceKeys + .Where(x => IsSameDevice(x.Device.MacAddress, macAddress)) + .ToList(); + + if (keysToRemove != null) + { + foreach (var key in keysToRemove) + { + _configManager?.TypedSecurityConfig?.DeviceKeys.Remove(key); + } + _configManager?.SaveAll(); + } + Log.Information($"Removed device key for {macAddress}"); return true; } @@ -147,6 +241,21 @@ public bool RemoveDeviceKey(string macAddress) } } + /// + /// Gets the private device key for local device + /// + /// The private device key, or null if not available + public DeviceKey? GetPrivateDeviceKey() + { + return _localDeviceKey is null + ? null + : new DeviceKey + { + Device = _localDeviceKey.Device, + RsaPrivateKeyPem = _localDeviceKey.RsaPrivateKeyPem + }; + } + /// /// Checks if a device is authorized /// @@ -248,7 +357,17 @@ public async Task DecryptStringAsync(string encryptedContent, string sou /// The hash string public string ComputeHash(string content) { - var hash = SHA1.HashData(Encoding.UTF8.GetBytes(content)); + return GetSHA1(content); + } + + /// + /// Computes SHA1 hash of a string + /// + /// The data to hash + /// The SHA1 hash string + public string GetSHA1(string data) + { + var hash = SHA1.HashData(Encoding.UTF8.GetBytes(data)); var sb = new StringBuilder(); foreach (var item in hash) @@ -259,13 +378,31 @@ public string ComputeHash(string content) return sb.ToString(); } + /// + /// Searches for a device key by device locator (instance method for interface) + /// + /// The device locator + /// The device key if found, otherwise null + public DeviceKey? SearchDeviceKey(DeviceLocator locator) + { + var existing = _configManager?.TypedSecurityConfig?.DeviceKeys + .FirstOrDefault(x => x.Device.IsSameDevice(locator)); + if (existing == null) return null; + return new DeviceKey + { + Device = existing.Device, + RsaPublicKeyPem = existing.RsaPublicKeyPem, + RsaPrivateKeyPem = existing.RsaPrivateKeyPem + }; + } + /// /// Encrypts a string with AES /// /// The source string /// The encryption key /// The encrypted string - public static string AesEncrypt(string source, string key) + public string AesEncrypt(string source, string key) { var data = Encoding.UTF8.GetBytes(source); var expandedKey = ExpandKey(key, 16); @@ -287,7 +424,7 @@ public static string AesEncrypt(string source, string key) /// The decryption key /// Whether the source is in Base64 /// The decrypted string - public static string AesDecrypt(string source, string key, bool isSourceInBase64 = true) + public string AesDecrypt(string source, string key, bool isSourceInBase64 = true) { var data = isSourceInBase64 ? Convert.FromBase64String(source) : Encoding.UTF8.GetBytes(source); var expandedKey = ExpandKey(key, 16); @@ -321,10 +458,29 @@ private static byte[] ExpandKey(string key, int length) private string GetMacAddress() { - // Get the first MAC address from network interfaces + // Get the MAC address of the network interface matching current IPv4 address try { + var ipv4 = NetworkHelper.GetInterNetworkIPv4(); var nics = System.Net.NetworkInformation.NetworkInterface.GetAllNetworkInterfaces(); + + // First, try to find the interface matching the current IPv4 address + foreach (var nic in nics) + { + if (nic.OperationalStatus == System.Net.NetworkInformation.OperationalStatus.Up + && (nic.NetworkInterfaceType == System.Net.NetworkInformation.NetworkInterfaceType.Ethernet + || nic.NetworkInterfaceType == System.Net.NetworkInformation.NetworkInterfaceType.Wireless80211)) + { + var addr = nic.GetIPProperties().UnicastAddresses + .FirstOrDefault(x => x.Address.ToString() == ipv4); + if (addr != null) + { + return nic.GetPhysicalAddress().ToString(); + } + } + } + + // Fallback: return the first Up interface's MAC address foreach (var nic in nics) { if (nic.OperationalStatus == System.Net.NetworkInformation.OperationalStatus.Up) @@ -341,6 +497,98 @@ private string GetMacAddress() return "Unknown"; } + /// + /// Checks if a device key is correct (instance method for interface) + /// + /// The device locator + /// The device key to verify + /// True if the key is correct + public bool IsDeviceKeyCorrect(DeviceLocator locator, DeviceKey key) + { + var existing = SearchDeviceKey(locator); + if (existing is null) return false; + return existing.IsSameKey(key); + } + + /// + /// Encrypts a string using RSA with a specific device's public key + /// + /// The device key containing the public key + /// The data to encrypt + /// The encrypted data as Base64 string + public string? RsaEncryptString(DeviceKey key, string data) + { + if (data.Length >= 90) + throw new ArgumentOutOfRangeException(nameof(data), "Data length is too long."); + + using var rsa = RSA.Create(2048); + rsa.ImportFromPem(key.RsaPublicKeyPem); + var dataBytes = Encoding.UTF8.GetBytes(data); + var encrypted = rsa.Encrypt(dataBytes, RSAEncryptionPadding.OaepSHA256); + return Convert.ToBase64String(encrypted); + } + + /// + /// Decrypts a string using RSA with a specific device's private key + /// + /// The device key containing the private key + /// The encrypted data as Base64 string + /// The decrypted data + public string? RsaDecryptString(DeviceKey key, string encryptedData) + { + using var rsa = RSA.Create(2048); + rsa.ImportFromPem(key.RsaPrivateKeyPem); + var dataBytes = Convert.FromBase64String(encryptedData); + var decrypted = rsa.Decrypt(dataBytes, RSAEncryptionPadding.OaepSHA256); + return Encoding.UTF8.GetString(decrypted); + } + + /// + /// Encrypts content using RSA+AES hybrid encryption + /// + /// The device key + /// The content to encrypt + /// The encrypted content + public EncryptedContent RsaEncryptContent(DeviceKey key, string content) + { + var aesKey = GenerateRandomKey(16); + var encryptedAesKey = RsaEncryptString(key, aesKey); + var encryptedContent = AesEncrypt(content, aesKey); + return new EncryptedContent + { + Device = key.Device, + RsaEncryptedAesKeyBase64 = encryptedAesKey, + AesEncryptedContentBase64 = encryptedContent, + }; + } + + /// + /// Decrypts content using RSA+AES hybrid decryption + /// + /// The device key + /// The encrypted content + /// The decrypted content + public string RsaDecryptContent(DeviceKey key, EncryptedContent content) + { + ArgumentNullException.ThrowIfNull(content.RsaEncryptedAesKeyBase64); + ArgumentNullException.ThrowIfNull(content.AesEncryptedContentBase64); + var aesKey = RsaDecryptString(key, content.RsaEncryptedAesKeyBase64); + return AesDecrypt(content.AesEncryptedContentBase64, aesKey!); + } + + /// + /// Generates a random key for AES encryption + /// + /// The key length + /// The random key as string + private static string GenerateRandomKey(int length) + { + var bytes = new byte[length]; + using var rng = RandomNumberGenerator.Create(); + rng.GetBytes(bytes); + return Convert.ToBase64String(bytes)[..length]; + } + /// /// Disposes the security manager /// diff --git a/KitX Clients/KitX Dashboard b/KitX Clients/KitX Dashboard index 075cfd75..2d988fc9 160000 --- a/KitX Clients/KitX Dashboard +++ b/KitX Clients/KitX Dashboard @@ -1 +1 @@ -Subproject commit 075cfd7556e5208e7b0404cdf0828fab9b468958 +Subproject commit 2d988fc92c66de2fd1f565dcc7f816e02c36a426 diff --git a/KitX Standard b/KitX Standard index be9302d5..dd21efb1 160000 --- a/KitX Standard +++ b/KitX Standard @@ -1 +1 @@ -Subproject commit be9302d5679b69f47cde4d67470dfb7d264e6652 +Subproject commit dd21efb1f7ba9257746d1a9e97df0b733a84de9e From f4ecf1a5cf5ad4cb49befcdbdd47c653be07a5ee Mon Sep 17 00:00:00 2001 From: StarInk Date: Sun, 15 Mar 2026 05:12:10 +0100 Subject: [PATCH 018/110] =?UTF-8?q?=F0=9F=92=BE=20Feat(Workflow,=20PluginS?= =?UTF-8?q?erver):=20To=20ensure=20the=20workflow=20and=20the=20plugin=20s?= =?UTF-8?q?erver=20can=20receive=20the=20response=20of=20the=20plugin(func?= =?UTF-8?q?tions).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../KitX.Core/Device/PluginsServer.cs | 28 +++++++- .../KitX.Core/Workflow/RealPluginManager.cs | 71 +++++++++++++++++-- .../Workflow/WorkflowScriptService.cs | 20 +++++- KitX Clients/KitX Dashboard | 2 +- KitX SDK | 2 +- 5 files changed, 113 insertions(+), 10 deletions(-) diff --git a/KitX Clients/KitX Core/KitX.Core/Device/PluginsServer.cs b/KitX Clients/KitX Core/KitX.Core/Device/PluginsServer.cs index 42984a6a..ca0020c4 100644 --- a/KitX Clients/KitX Core/KitX.Core/Device/PluginsServer.cs +++ b/KitX Clients/KitX Core/KitX.Core/Device/PluginsServer.cs @@ -85,10 +85,17 @@ public class PluginsServer : IPluginServer /// public event EventHandler? PluginUnregistered; + /// + /// Event raised when a plugin sends a response (has RequestId) + /// + public event EventHandler? PluginResponse; + /// /// Private constructor /// - private PluginsServer() { } + private PluginsServer() + { + } /// /// Initializes the server @@ -116,6 +123,17 @@ public IPluginServer Run() _status = ServerStatus.Starting; + // Initialize RealPluginManager when server starts, so it can receive plugin messages + try + { + _ = new KitX.Core.Workflow.RealPluginManager(this); + Log.Information("[PluginsServer] RealPluginManager initialized for message handling"); + } + catch (Exception ex) + { + Log.Warning(ex, "[PluginsServer] Failed to initialize RealPluginManager"); + } + const int maxRetries = 5; const int startPort = 7777; int currentPort = startPort; @@ -224,6 +242,7 @@ public IPluginServer Run() Log.Warning(ex, "Error handling plugin message"); } + Log.Information($"[PluginsServer] Invoking PluginMessageReceived event for connection {connectionId}"); PluginMessageReceived?.Invoke(this, new PluginMessageReceivedEventArgs { ConnectionId = connectionId, @@ -231,6 +250,13 @@ public IPluginServer Run() }); }; + // Forward PluginResponse events from PluginConnection to PluginsServer.PluginResponse + connection.PluginResponse += (sender, args) => + { + Log.Information($"[PluginsServer] Forwarding PluginResponse event, RequestId: {args.RequestId}"); + PluginResponse?.Invoke(this, args); + }; + connection.Initialize(); PluginConnected?.Invoke(this, new PluginConnectedEventArgs diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/RealPluginManager.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/RealPluginManager.cs index 71c47e8c..71e2174c 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/RealPluginManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/RealPluginManager.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using Kscript.CSharp.Parser.Core; using Kscript.CSharp.Parser.Models; +using KitX.Core.Contract.Plugin; using KitX.Core.Device; using KitX.Shared.CSharp.Plugin; using KitX.Shared.CSharp.WebCommand; @@ -49,6 +50,38 @@ public RealPluginManager(PluginsServer pluginsServer) // 订阅插件消息接收事件以处理响应 _pluginsServer.PluginMessageReceived += OnPluginMessageReceived; + + // 订阅插件响应事件(当插件返回带RequestId的响应时触发) + _pluginsServer.PluginResponse += OnPluginResponse; + } + + /// + /// 处理插件响应事件 + /// + private void OnPluginResponse(object? sender, PluginResponseEventArgs e) + { + try + { + Log.Information($"[RealPluginManager] OnPluginResponse called with RequestId: {e.RequestId}"); + + if (_pendingResponses.TryRemove(e.RequestId, out var tcs)) + { + var command = JsonSerializer.Deserialize(e.Content, _serializerOptions); + var responseBody = command.BodyLength > 0 + ? Encoding.UTF8.GetString(command.Body.AsSpan(0, command.BodyLength)) + : string.Empty; + Log.Information($"[RealPluginManager] Setting result from PluginResponse: {responseBody}"); + tcs.SetResult(responseBody); + } + else + { + Log.Warning($"[RealPluginManager] RequestId {e.RequestId} not found in pending responses"); + } + } + catch (Exception ex) + { + Log.Warning(ex, "[RealPluginManager] Error handling plugin response"); + } } /// @@ -58,8 +91,16 @@ private void OnPluginMessageReceived(object? sender, PluginMessageReceivedEventA { try { + Log.Information($"[RealPluginManager] OnPluginMessageReceived called with message: {e.Message?.Substring(0, Math.Min(200, e.Message?.Length ?? 0))}..."); + var kwc = JsonSerializer.Deserialize(e.Message, _serializerOptions); - if (kwc?.Content is null) return; + if (kwc?.Content is null) + { + Log.Information($"[RealPluginManager] kwc or kwc.Content is null"); + return; + } + + Log.Information($"[RealPluginManager] kwc.Content: {kwc.Content.Substring(0, Math.Min(100, kwc.Content.Length))}..."); var command = JsonSerializer.Deserialize(kwc.Content, _serializerOptions); if (command.Request is null) return; // Command is a struct, check if Request is empty @@ -67,13 +108,19 @@ private void OnPluginMessageReceived(object? sender, PluginMessageReceivedEventA // 检查是否是响应消息 if (command.Tags != null && command.Tags.TryGetValue("RequestId", out var requestId)) { + Log.Information($"[RealPluginManager] Found RequestId: {requestId}"); if (_pendingResponses.TryRemove(requestId, out var tcs)) { var responseBody = command.BodyLength > 0 ? Encoding.UTF8.GetString(command.Body.AsSpan(0, command.BodyLength)) : string.Empty; + Log.Information($"[RealPluginManager] Setting result: {responseBody}"); tcs.SetResult(responseBody); } + else + { + Log.Warning($"[RealPluginManager] RequestId {requestId} not found in pending responses"); + } } } catch (Exception ex) @@ -178,10 +225,24 @@ private async Task CallAsync(PluginCallInfo callInfo) Log.Information($"[RealPluginManager] Sent request to {callInfo.PluginName}.{callInfo.MethodName}, RequestId: {requestId}"); - // 注意: Loader 在处理 ReceiveCommand 后不会返回响应 - // 对于 void 方法,我们直接返回空结果 - // 对于有返回值的方法,需要插件支持返回响应(当前不支持) - return string.Empty; + // 等待插件响应,设置超时 + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + try + { + return await tcs.Task.WaitAsync(cts.Token); + } + catch (TimeoutException) + { + Log.Warning($"[RealPluginManager] Request {requestId} timed out"); + _pendingResponses.TryRemove(requestId, out _); + throw new TimeoutException($"Plugin call timed out: {callInfo.PluginName}.{callInfo.MethodName}"); + } + catch (OperationCanceledException) + { + Log.Warning($"[RealPluginManager] Request {requestId} was cancelled"); + _pendingResponses.TryRemove(requestId, out _); + throw; + } } catch (Exception ex) { diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs index b6b2d3c8..035e1d0c 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs @@ -49,9 +49,25 @@ public class WorkflowScriptService : IWorkflowService private List _availablePlugins { get; set; } = new(); /// - /// Private constructor + /// Private constructor - initializes RealPluginManager immediately /// - private WorkflowScriptService() { } + private WorkflowScriptService() + { + // Pre-initialize RealPluginManager to ensure it subscribes to plugin events + // This must be done at startup, not when first script is executed + try + { + var pluginsServer = PluginsServer.Instance; + var realPluginManager = new RealPluginManager(pluginsServer); + Parser.SetPluginManager(realPluginManager); + _isParserInitialized = true; + Log.Information("[WorkflowScriptService] Real plugin manager pre-initialized at startup"); + } + catch (Exception ex) + { + Log.Warning(ex, "[WorkflowScriptService] Failed to pre-initialize RealPluginManager, will retry on first script execution"); + } + } /// /// Gets the script engine, creating it if necessary diff --git a/KitX Clients/KitX Dashboard b/KitX Clients/KitX Dashboard index 2d988fc9..3c641a69 160000 --- a/KitX Clients/KitX Dashboard +++ b/KitX Clients/KitX Dashboard @@ -1 +1 @@ -Subproject commit 2d988fc92c66de2fd1f565dcc7f816e02c36a426 +Subproject commit 3c641a695ca684844469582e9f7b848e611cbb33 diff --git a/KitX SDK b/KitX SDK index 07e5b193..0b917766 160000 --- a/KitX SDK +++ b/KitX SDK @@ -1 +1 @@ -Subproject commit 07e5b193c4b1d74a9f2d62f400cd6f933d97e877 +Subproject commit 0b9177663bed43653631f62d7f52067cf0cb34d9 From 269319436c68e391a8686bc0683667263bd10ede Mon Sep 17 00:00:00 2001 From: StarInk Date: Sun, 15 Mar 2026 14:28:46 +0100 Subject: [PATCH 019/110] =?UTF-8?q?=F0=9F=92=BE=20Feat(Workflow,=20PluginS?= =?UTF-8?q?erver):=20=E9=80=82=E9=85=8DParser=E7=9A=84=E2=80=9C=E4=BD=BF?= =?UTF-8?q?=E7=94=A8=E7=94=B1=E6=8F=92=E4=BB=B6=E5=8E=9F=E5=A7=8B=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E7=9A=84=E5=8F=82=E6=95=B0=E5=90=8D=E7=A7=B0=E4=BD=9C?= =?UTF-8?q?=E4=B8=BA=E5=8F=82=E6=95=B0=E5=90=8D=E7=A7=B0=E2=80=9D=E7=9A=84?= =?UTF-8?q?=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../KitX Core/KitX.Core/Workflow/RealPluginManager.cs | 9 +++++---- .../KitX.Core/Workflow/WorkflowScriptService.cs | 2 +- KitX SDK | 2 +- KitX Standard | 2 +- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/RealPluginManager.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/RealPluginManager.cs index 71e2174c..0f6f7627 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/RealPluginManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/RealPluginManager.cs @@ -198,13 +198,14 @@ private async Task CallAsync(PluginCallInfo callInfo) for (int i = 0; i < callInfo.Parameters.Length; i++) { var paramValue = callInfo.Parameters[i]?.ToString() ?? string.Empty; + var paramName = callInfo.ParameterNames?.Length > i ? callInfo.ParameterNames[i] : i.ToString(); + var paramType = callInfo.ParameterTypes?.Length > i ? callInfo.ParameterTypes[i].Name.ToLower() : "string"; command.FunctionArgs.Add(new Parameter { - Name = i.ToString(), + Name = paramName, + Type = paramType, Value = paramValue, - Type = callInfo.ParameterTypes?.Length > i - ? callInfo.ParameterTypes[i].Name.ToLower() - : "string" + IsOptional = false }); } command.Body = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(command.FunctionArgs, _serializerOptions)); diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs index 035e1d0c..75bf6c20 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs @@ -264,7 +264,7 @@ public void RemoveWorkflow(string workflowId) catch (Exception ex) { var error = $"Failed to generate plugin API: {ex.Message}"; - Log.Error(error); + Log.Error(ex, error); } // Execute script with plugins diff --git a/KitX SDK b/KitX SDK index 0b917766..1539dcbc 160000 --- a/KitX SDK +++ b/KitX SDK @@ -1 +1 @@ -Subproject commit 0b9177663bed43653631f62d7f52067cf0cb34d9 +Subproject commit 1539dcbc18f13c91972fed4015eb0658296044b2 diff --git a/KitX Standard b/KitX Standard index dd21efb1..05cc0009 160000 --- a/KitX Standard +++ b/KitX Standard @@ -1 +1 @@ -Subproject commit dd21efb1f7ba9257746d1a9e97df0b733a84de9e +Subproject commit 05cc000900c0dd6ba6a0a451084f28e9a19d93e5 From 010e476a71dd6e5ee92fa17914ef2e8a234e870c Mon Sep 17 00:00:00 2001 From: StarInk Date: Wed, 18 Mar 2026 12:47:27 +0100 Subject: [PATCH 020/110] =?UTF-8?q?=F0=9F=94=A7=20Fix(PluginsServer):=20?= =?UTF-8?q?=E7=A7=BB=E9=99=A4=E5=86=97=E4=BD=99=E7=9A=84=E5=88=9D=E5=A7=8B?= =?UTF-8?q?=E5=8C=96=E6=9C=8D=E5=8A=A1=E5=99=A8=E7=9A=84=E6=96=B9=E6=B3=95?= =?UTF-8?q?=E5=B9=B6=E8=B0=83=E6=95=B4=E7=AB=AF=E5=8F=A3=E5=A4=84=E7=90=86?= =?UTF-8?q?=E9=80=BB=E8=BE=91=E4=BB=A5=E9=81=BF=E5=85=8D=E6=9D=83=E9=99=90?= =?UTF-8?q?=E6=88=96=E7=AB=AF=E5=8F=A3=E5=8D=A0=E7=94=A8=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../KitX.Core/Device/PluginsServer.cs | 56 ++++++++++--------- 1 file changed, 30 insertions(+), 26 deletions(-) diff --git a/KitX Clients/KitX Core/KitX.Core/Device/PluginsServer.cs b/KitX Clients/KitX Core/KitX.Core/Device/PluginsServer.cs index ca0020c4..3db642c3 100644 --- a/KitX Clients/KitX Core/KitX.Core/Device/PluginsServer.cs +++ b/KitX Clients/KitX Core/KitX.Core/Device/PluginsServer.cs @@ -97,21 +97,6 @@ private PluginsServer() { } - /// - /// Initializes the server - /// - private void InitializeServer() - { - // Use configured port from ConstantTable, or default to 0 (dynamic port) - var port = ConstantTable.PluginsServerPort > 0 - ? ConstantTable.PluginsServerPort - : 0; // 0 means dynamic port assignment - - port = port is >= 0 and <= 65535 ? port : 0; - - _server ??= new WebSocketServer($"ws://0.0.0.0:{port}"); - } - /// /// Runs the plugins server with retry logic for port conflicts /// @@ -134,7 +119,7 @@ public IPluginServer Run() Log.Warning(ex, "[PluginsServer] Failed to initialize RealPluginManager"); } - const int maxRetries = 5; + const int maxRetries = 10; const int startPort = 7777; int currentPort = startPort; bool serverStarted = false; @@ -143,14 +128,19 @@ public IPluginServer Run() { try { - // Try different ports if not using dynamic port + // Determine port for this attempt if (ConstantTable.PluginsServerPort <= 0) { currentPort = startPort + retryCount; - _server = new WebSocketServer($"ws://0.0.0.0:{currentPort}"); + // Use 127.0.0.1 instead of 0.0.0.0 to avoid permission issues + _server = new WebSocketServer($"ws://127.0.0.1:{currentPort}"); + } + else + { + // Use configured port + currentPort = ConstantTable.PluginsServerPort; + _server = new WebSocketServer($"ws://127.0.0.1:{currentPort}"); } - - InitializeServer(); _server!.Start(socket => { @@ -239,7 +229,7 @@ public IPluginServer Run() } catch (Exception ex) { - Log.Warning(ex, "Error handling plugin message"); + Log.Warning(ex, "[PluginsServer] Error handling plugin message"); } Log.Information($"[PluginsServer] Invoking PluginMessageReceived event for connection {connectionId}"); @@ -271,16 +261,30 @@ public IPluginServer Run() // Update ConstantTable with the actual port ConstantTable.PluginsServerPort = Port ?? 0; - Log.Information($"PluginsServer started on port {Port}"); + Log.Information($"[PluginsServer] PluginsServer started on port {Port}"); // Publish port changed event via EventService only (removed direct PortChanged event to avoid potential recursion) EventService.Instance.Publish(EventNames.PluginsServerPortChanged, new PortChangedEventArgs { Port = Port ?? 0 }); } - catch (Exception ex) when (ex.Message.Contains("access") || ex.Message.Contains("used")) + catch (System.Net.Sockets.SocketException ex) + { + Log.Warning(ex, $"[PluginsServer] Socket error on port {currentPort}: {ex.Message} (attempt {retryCount + 1}/{maxRetries})"); + _server?.Dispose(); + _server = null; + + // If using a fixed port, don't retry + if (ConstantTable.PluginsServerPort > 0) + break; + } + catch (Exception ex) { - Log.Warning($"Port {currentPort} is in use, trying next port... ({retryCount + 1}/{maxRetries})"); + Log.Error(ex, $"[PluginsServer] Unexpected error starting PluginsServer on port {currentPort}"); _server?.Dispose(); _server = null; + + // If using a fixed port, don't retry + if (ConstantTable.PluginsServerPort > 0) + break; } } @@ -348,12 +352,12 @@ public void Stop() _connections.Clear(); - Log.Information("PluginsServer stopped"); + Log.Information("[PluginsServer] PluginsServer stopped"); _status = ServerStatus.Pending; } catch (Exception ex) { - Log.Error(ex, "Error stopping PluginsServer"); + Log.Error(ex, "[PluginsServer] Error stopping PluginsServer"); _status = ServerStatus.Errored; } } From 1009226a48ecc85ac7d25d18ff8d0f3541b3af43 Mon Sep 17 00:00:00 2001 From: StarInk Date: Wed, 18 Mar 2026 19:27:10 +0100 Subject: [PATCH 021/110] =?UTF-8?q?=F0=9F=92=BE=20Feat(Workflow):=20?= =?UTF-8?q?=E8=BF=9B=E4=B8=80=E6=AD=A5=E5=AE=9E=E7=8E=B0Workflow=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=B7=BB=E5=8A=A0=E5=8F=AF?= =?UTF-8?q?=E5=8F=98=E5=B8=B8=E9=87=8F=E3=80=81=E8=BE=85=E5=8A=A9=E5=87=BD?= =?UTF-8?q?=E6=95=B0=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=B7=BB=E5=8A=A0.kcs?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E4=BF=9D=E5=AD=98=E4=B8=8E=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=8C=E9=99=90=E5=88=B6=E4=B8=BB=E7=A8=8B?= =?UTF-8?q?=E5=BA=8F=E7=9A=84=E8=AF=AD=E6=B3=95=E5=8A=9F=E8=83=BD=E4=BB=A5?= =?UTF-8?q?=E4=BE=BF=E4=BA=8E=E5=90=8E=E7=BB=AD=E7=9A=84=E4=BB=A3=E7=A0=81?= =?UTF-8?q?-=E8=8A=82=E7=82=B9=E5=9B=BE=E4=BA=92=E8=AF=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DI/CoreServiceCollectionExtensions.cs | 16 + .../KitX.Core/Device/NetworkHelper.cs | 39 ++- .../KitX.Core/Workflow/KcsFileService.cs | 172 ++++++++++ .../KitX.Core/Workflow/RealPluginManager.cs | 78 ++++- .../Workflow/WorkflowScriptService.cs | 320 ++++++++++++++++++ KitX Clients/KitX Dashboard | 2 +- KitX Standard | 2 +- 7 files changed, 623 insertions(+), 6 deletions(-) create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/KcsFileService.cs diff --git a/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs b/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs index ca02865d..b72fc0d8 100644 --- a/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs +++ b/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs @@ -157,6 +157,22 @@ public static IServiceCollection AddCoreServices(this IServiceCollection service return service; }); + // KCS File Services + Log.Information("Registering IKcsFileService..."); + services.AddSingleton(provider => + { + var service = new KcsFileService(); + return service; + }); + + // Main Program Analyzer + Log.Information("Registering IMainProgramAnalyzer..."); + services.AddSingleton(provider => + { + var service = new MainProgramAnalyzer(); + return service; + }); + Log.Information("AddCoreServices completed."); return services; } diff --git a/KitX Clients/KitX Core/KitX.Core/Device/NetworkHelper.cs b/KitX Clients/KitX Core/KitX.Core/Device/NetworkHelper.cs index 2f39920e..c0282303 100644 --- a/KitX Clients/KitX Core/KitX.Core/Device/NetworkHelper.cs +++ b/KitX Clients/KitX Core/KitX.Core/Device/NetworkHelper.cs @@ -16,7 +16,7 @@ namespace KitX.Core.Device; internal static class NetworkHelper { /// - /// Gets the local IPv4 address + /// Gets the local IPv4 address (excluding Docker and virtual interfaces) /// /// IPv4 address or empty string if not found internal static string GetInterNetworkIPv4() @@ -30,7 +30,8 @@ internal static string GetInterNetworkIPv4() .Where(ip => ip.AddressFamily == AddressFamily.InterNetwork && !ip.ToString().Equals("127.0.0.1") && - IsInterNetworkAddressV4(ip)) + IsInterNetworkAddressV4(ip) && + !IsExcludedNetworkInterface(ip)) .FirstOrDefault(); var result = search?.ToString(); @@ -44,6 +45,40 @@ internal static string GetInterNetworkIPv4() } } + /// + /// Checks if the IP belongs to an excluded network interface (e.g., Docker) + /// + private static bool IsExcludedNetworkInterface(IPAddress ip) + { + try + { + var nics = NetworkInterface.GetAllNetworkInterfaces() + .Where(n => n.OperationalStatus == OperationalStatus.Up); + + foreach (var nic in nics) + { + var description = nic.Description.ToLowerInvariant(); + // Exclude Docker, veth (virtual ethernet), Hyper-V, etc. + if (description.Contains("docker") || + description.Contains("veth") || + description.Contains("hyper-v") || + description.Contains("virtual")) + { + var addresses = nic.GetIPProperties().UnicastAddresses; + if (addresses.Any(a => a.Address.ToString() == ip.ToString())) + { + return true; + } + } + } + } + catch + { + // If we can't determine, don't exclude + } + return false; + } + /// /// Gets the local IPv6 address /// diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/KcsFileService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/KcsFileService.cs new file mode 100644 index 00000000..a6f9ac4e --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/KcsFileService.cs @@ -0,0 +1,172 @@ +using System; +using System.IO; +using System.Text.Json; +using System.Threading.Tasks; +using KitX.Core.Contract.Workflow; +using Serilog; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace KitX.Core.Workflow; + +/// +/// KCS文件服务实现 - 仅负责KCS文件的读写 +/// +public class KcsFileService : IKcsFileService +{ + /// + /// 加载KCS文件 + /// + public async System.Threading.Tasks.Task LoadKcsFileAsync(string filePath) + { + try + { + var json = await File.ReadAllTextAsync(filePath); + var kcs = JsonSerializer.Deserialize(json); + return kcs; + } + catch (Exception ex) + { + Log.Error(ex, "[KcsFileService] Error loading KCS file: {FilePath}", filePath); + return null; + } + } + + /// + /// 保存KCS文件 + /// + public async System.Threading.Tasks.Task SaveKcsFileAsync(string filePath, KcsFileFormat kcs) + { + try + { + var json = JsonSerializer.Serialize(kcs, new JsonSerializerOptions + { + WriteIndented = true + }); + + await File.WriteAllTextAsync(filePath, json); + Log.Information("[KcsFileService] KCS file saved: {FilePath}", filePath); + } + catch (Exception ex) + { + Log.Error(ex, "[KcsFileService] Error saving KCS file: {FilePath}", filePath); + throw; + } + } +} + +/// +/// 主程序代码分析器实现 - 使用 CSharpSyntaxWalker 检查禁止的语法 +/// +public class MainProgramAnalyzer : IMainProgramAnalyzer +{ + /// + /// 分析代码 + /// + public MainProgramAnalysisResult Analyze(string code) + { + var result = new MainProgramAnalysisResult { IsValid = true }; + + if (string.IsNullOrWhiteSpace(code)) + { + return result; + } + + try + { + // 解析代码为语法树 + var syntaxTree = CSharpSyntaxTree.ParseText(code); + var root = syntaxTree.GetRoot(); + + // 使用 StrictScriptValidator 进行语法检查 + var validator = new StrictScriptValidator(); + validator.Visit(root); + + result.IsValid = validator.IsValid; + result.ForbiddenReason = validator.ForbiddenReason; + } + catch (Exception ex) + { + result.IsValid = false; + result.ForbiddenReason = $"Code parsing error: {ex.Message}"; + Log.Warning(ex, "[MainProgramAnalyzer] Error analyzing code"); + } + + return result; + } +} + +/// +/// 严格的脚本验证器 - 使用 CSharpSyntaxWalker 检查允许的语法 +/// +internal class StrictScriptValidator : CSharpSyntaxWalker +{ + public bool IsValid { get; private set; } = true; + public string ForbiddenReason { get; private set; } = ""; + + public override void Visit(SyntaxNode? node) + { + if (!IsValid || node == null) return; + + // 1. 允许的基础结构 + if (node is CompilationUnitSyntax || // 脚本根节点 + node is GlobalStatementSyntax || // 全局语句 + node is ExpressionStatementSyntax) // 表达式语句(如 a = 1; 或 Func();) + { + base.Visit(node); + return; + } + + // 2. 允许:变量定义与赋值 + // 包括:int a = 1; (LocalDeclaration) 和 a = 2; (AssignmentExpression) + if (node is LocalDeclarationStatementSyntax || + node is VariableDeclarationSyntax || + node is VariableDeclaratorSyntax || + node is EqualsValueClauseSyntax || // 变量初始化 = 值 + node is AssignmentExpressionSyntax || + node is IdentifierNameSyntax || // 变量名 + node is PredefinedTypeSyntax || // 基本类型关键字 (int, string) + node is LiteralExpressionSyntax) // 字面量 (123, "hello") + { + base.Visit(node); + return; + } + + // 3. 允许:函数调用 + // 包括:MyFunc(a, 10); + if (node is InvocationExpressionSyntax || + node is ArgumentListSyntax || + node is ArgumentSyntax || + node is MemberAccessExpressionSyntax || // 成员访问,如 obj.Member + node is MemberBindingExpressionSyntax || // 成员绑定,如 ?.Member + node is InterpolatedStringExpressionSyntax || // 字符串插值,如 $"Hello {name}" + node is InterpolatedStringTextSyntax || // 字符串插值文本部分 + node is InterpolationSyntax) // 插值表达式 {xxx} + { + base.Visit(node); + return; + } + + // 4. 允许:return 语句 + if (node is ReturnStatementSyntax) + { + base.Visit(node); + return; + } + + // 5. 允许:类型转换 + if (node is CastExpressionSyntax) + { + base.Visit(node); + return; + } + + // 6. 禁止一切其他行为 + // 此时 node 可能是 BinaryExpressionSyntax (加减乘除), IfStatementSyntax (条件), WhileStatementSyntax (循环) 等 + IsValid = false; + var location = node.GetLocation(); + var lineSpan = location.GetLineSpan(); + ForbiddenReason = $"Unsupported syntax: {node.Kind()} at line {lineSpan.StartLinePosition.Line + 1}"; + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/RealPluginManager.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/RealPluginManager.cs index 0f6f7627..02ba927f 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/RealPluginManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/RealPluginManager.cs @@ -132,10 +132,14 @@ private void OnPluginMessageReceived(object? sender, PluginMessageReceivedEventA /// /// 调用插件方法(无返回值) /// + /// + /// 使用 fire-and-forget 模式,不等待插件响应。 + /// 因为 void 返回类型的插件功能不会发送响应。 + /// public void Call(PluginCallInfo callInfo) { - Log.Information($"[RealPluginManager] Call() invoked: {callInfo.PluginName}.{callInfo.MethodName}"); - CallAsync(callInfo).Wait(); + Log.Information($"[RealPluginManager] Call() invoked (fire-and-forget): {callInfo.PluginName}.{callInfo.MethodName}"); + SendRequestWithoutWaitAsync(callInfo); } /// @@ -252,6 +256,76 @@ private async Task CallAsync(PluginCallInfo callInfo) } } + /// + /// 发送请求但不等待响应(fire-and-forget) + /// + /// + /// 用于 void 返回类型的插件调用,因为这类调用不会有响应返回。 + /// + private void SendRequestWithoutWaitAsync(PluginCallInfo callInfo) + { + // 查找插件连接 + var connection = FindPluginConnection(callInfo.PluginName); + if (connection is null) + { + Log.Error($"[RealPluginManager] Plugin connection not found: {callInfo.PluginName}"); + throw new InvalidOperationException($"Plugin not found or not connected: {callInfo.PluginName}"); + } + + Log.Information($"[RealPluginManager] Sending fire-and-forget request to {callInfo.PluginName}.{callInfo.MethodName}"); + + try + { + // 构建命令(不包含 RequestId,因为不需要等待响应) + var command = new Command + { + Request = CommandRequestInfo.ReceiveCommand, + FunctionName = callInfo.MethodName, + PluginConnectionId = connection.ConnectionId ?? string.Empty + }; + + // 处理参数 + if (callInfo.Parameters != null && callInfo.Parameters.Length > 0) + { + command.FunctionArgs = new(); + for (int i = 0; i < callInfo.Parameters.Length; i++) + { + var paramValue = callInfo.Parameters[i]?.ToString() ?? string.Empty; + var paramName = callInfo.ParameterNames?.Length > i ? callInfo.ParameterNames[i] : i.ToString(); + var paramType = callInfo.ParameterTypes?.Length > i ? callInfo.ParameterTypes[i].Name.ToLower() : "string"; + command.FunctionArgs.Add(new Parameter + { + Name = paramName, + Type = paramType, + Value = paramValue, + IsOptional = false + }); + } + command.Body = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(command.FunctionArgs, _serializerOptions)); + command.BodyLength = command.Body.Length; + } + + // 构建请求 + var request = new Request + { + Type = RequestTypes.Command, + Version = RequestVersions.V1, + Content = JsonSerializer.Serialize(command, _serializerOptions) + }; + + // 发送请求 + var message = JsonSerializer.Serialize(request, _serializerOptions); + connection.Send(message); + + Log.Information($"[RealPluginManager] Fire-and-forget request sent to {callInfo.PluginName}.{callInfo.MethodName}"); + } + catch (Exception ex) + { + Log.Error(ex, $"[RealPluginManager] Error sending fire-and-forget request to {callInfo.PluginName}.{callInfo.MethodName}"); + throw; + } + } + /// /// 解析结果 /// diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs index 75bf6c20..b2532690 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs @@ -8,7 +8,9 @@ using System.Threading; using System.Threading.Tasks; using Csharpell.Core; +using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.CSharp.Scripting; using Kscript.CSharp.Parser; using KitX.Core.Contract.Workflow; @@ -498,6 +500,324 @@ public void UpdateAvailablePlugins(List plugins) _availablePlugins = plugins ?? new List(); Log.Information($"[WorkflowScriptService] Updated available plugins: {_availablePlugins.Count} plugins"); } + + #region KCS Script Processing Methods + + /// + /// 从代码中解析常量 + /// + public List ParseConstantsFromCode(string code) + { + var result = new List(); + + if (string.IsNullOrWhiteSpace(code)) + return result; + + // 简单的const解析 - 匹配 "const 类型 变量名 = 值;" 模式 + var lines = code.Split('\n'); + var newConstants = new Dictionary(); + + foreach (var line in lines) + { + var trimmed = line.Trim(); + if (trimmed.StartsWith("const ")) + { + // 解析 const 类型 名称 = 值; + var parts = trimmed.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length >= 4 && parts[0] == "const") + { + var type = parts[1]; + var name = parts[2]; + var valueStr = string.Join(" ", parts.Skip(3)).TrimStart('=').Trim().TrimEnd(';'); + + if (!newConstants.ContainsKey(name)) + { + var defaultValue = ParseValue(valueStr, type); + + newConstants[name] = new VariableConstant + { + Name = name, + DefaultValue = defaultValue, + UserValue = defaultValue, + Type = type + }; + } + } + } + } + + return newConstants.Values.ToList(); + } + + /// + /// 解析常量值 + /// + private object? ParseValue(string valueStr, string type) + { + if (string.IsNullOrEmpty(valueStr)) return null; + + try + { + return type switch + { + "int" => int.TryParse(valueStr, out var i) ? i : 0, + "double" => double.TryParse(valueStr, out var d) ? d : 0.0, + "float" => float.TryParse(valueStr, out var f) ? f : 0.0f, + "bool" => bool.TryParse(valueStr, out var b) && b, + "string" => valueStr.Trim('"').Trim('\''), + _ => valueStr + }; + } + catch + { + return valueStr; + } + } + + /// + /// 应用常量到代码 + /// + /// + /// 使用 CSharpSyntaxRewriter 进行语法树级别的值注入,比正则表达式更准确可靠, + /// 不会误匹配注释或字符串中的内容。 + /// + public string ApplyConstantsToCode(string code, List constants) + { + if (string.IsNullOrWhiteSpace(code) || constants == null || !constants.Any()) + return code; + + // 构建常量名称到值的字典 + var constantValues = new Dictionary(); + foreach (var constant in constants) + { + // 根据类型转换 UserValue + var typedValue = constant.UserValue; + if (typedValue != null && constant.Type != null) + { + typedValue = ConvertToTypedValue(typedValue, constant.Type); + } + constantValues[constant.Name] = typedValue; + } + + // 解析代码为语法树 + var tree = CSharpSyntaxTree.ParseText(code); + var root = tree.GetRoot(); + + // 使用语法重写器注入常量值 + var rewriter = new ConstantValueRewriter(constantValues); + var newRoot = rewriter.Visit(root); + + return newRoot.ToFullString(); + } + + /// + /// 将值转换为指定类型 + /// + private object? ConvertToTypedValue(object? value, string type) + { + if (value == null) return null; + + return type.ToLowerInvariant() switch + { + "int" => Convert.ToInt32(value), + "long" => Convert.ToInt64(value), + "double" => Convert.ToDouble(value), + "float" => Convert.ToSingle(value), + "decimal" => Convert.ToDecimal(value), + "bool" or "boolean" => Convert.ToBoolean(value), + "string" => value.ToString(), + "char" => Convert.ToChar(value), + _ => value + }; + } + + /// + /// 合并辅助函数到代码 + /// + public string MergeHelperFunctions(string mainCode, List helperFunctions) + { + var combined = new StringBuilder(); + + // 添加辅助函数作为完整的 C# 方法 + foreach (var func in helperFunctions) + { + // 生成方法签名 + combined.Append("static "); + combined.Append(func.ReturnType); + combined.Append(" "); + combined.Append(func.Name); + combined.Append("("); + + // 添加参数 + for (int i = 0; i < func.Parameters.Count; i++) + { + if (i > 0) combined.Append(", "); + combined.Append(func.Parameters[i].Type); + combined.Append(" "); + combined.Append(func.Parameters[i].Name); + } + + combined.AppendLine(")"); + combined.AppendLine("{"); + + // 添加函数体代码(用户提供的代码被视为方法体内容) + if (!string.IsNullOrWhiteSpace(func.Code)) + { + // 逐行添加函数体,保持缩进 + foreach (var line in func.Code.Split('\n')) + { + combined.AppendLine(" " + line); + } + } + + combined.AppendLine("}"); + combined.AppendLine(); + } + + // 添加主程序 + combined.AppendLine("// --- Main Program ---"); + combined.AppendLine(mainCode); + + return combined.ToString(); + } + + /// + /// 执行KCS代码 - 包含代码分析、常量应用、辅助函数合并 + /// + public async Task ExecuteKcsCodesAsync( + string mainCode, + List helperFunctions, + List constants, + List? requiredPlugins = null, + bool includeTimestamp = true, + CancellationToken cancellationToken = default) + { + // 1. 分析主程序代码 + var analyzer = new MainProgramAnalyzer(); + var analysisResult = analyzer.Analyze(mainCode); + + if (!analysisResult.IsValid) + { + return includeTimestamp + ? $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [E] Code analysis failed: {analysisResult.ForbiddenReason}" + : $"Code analysis failed: {analysisResult.ForbiddenReason}"; + } + + // 2. 应用常量 + var codeWithConstants = ApplyConstantsToCode(mainCode, constants); + + // 3. 合并辅助函数 + var fullCode = MergeHelperFunctions(codeWithConstants, helperFunctions); + + // 4. 执行代码 + return await ExecuteCodesAsync(fullCode, requiredPlugins, includeTimestamp, cancellationToken); + } + + #endregion +} + +/// +/// Rewriter for injecting constant values into variable declarations using CSharpSyntaxRewriter +/// +/// +/// This approach is more reliable than regex-based matching as it understands the actual +/// syntax structure and won't accidentally match content in comments or strings. +/// +internal class ConstantValueRewriter : CSharpSyntaxRewriter +{ + private readonly Dictionary _constantValues; + + public ConstantValueRewriter(Dictionary constantValues) + { + _constantValues = constantValues; + } + + /// + /// Visits variable declarators to replace their initializer values + /// + public override SyntaxNode? VisitVariableDeclarator(VariableDeclaratorSyntax node) + { + // Check if this variable declarator has an initializer and matches a constant name + if (node.Initializer != null && _constantValues.TryGetValue(node.Identifier.Text, out var newValue)) + { + var newInitializer = CreateNewInitializer(node.Initializer, newValue); + if (newInitializer != null) + { + return node.WithInitializer(newInitializer); + } + } + + return base.VisitVariableDeclarator(node); + } + + /// + /// Creates a new EqualsValueClauseSyntax with the specified value + /// + private EqualsValueClauseSyntax? CreateNewInitializer(EqualsValueClauseSyntax oldInitializer, object? value) + { + ExpressionSyntax? newExpression = null; + + if (value == null) + { + newExpression = SyntaxFactory.LiteralExpression(SyntaxKind.NullLiteralExpression); + } + else if (value is int intVal) + { + newExpression = SyntaxFactory.LiteralExpression( + SyntaxKind.NumericLiteralExpression, + SyntaxFactory.Literal(intVal)); + } + else if (value is long longVal) + { + newExpression = SyntaxFactory.LiteralExpression( + SyntaxKind.NumericLiteralExpression, + SyntaxFactory.Literal(longVal)); + } + else if (value is double doubleVal) + { + newExpression = SyntaxFactory.LiteralExpression( + SyntaxKind.NumericLiteralExpression, + SyntaxFactory.Literal(doubleVal)); + } + else if (value is float floatVal) + { + newExpression = SyntaxFactory.LiteralExpression( + SyntaxKind.NumericLiteralExpression, + SyntaxFactory.Literal(floatVal)); + } + else if (value is decimal decimalVal) + { + newExpression = SyntaxFactory.LiteralExpression( + SyntaxKind.NumericLiteralExpression, + SyntaxFactory.Literal(decimalVal)); + } + else if (value is bool boolVal) + { + newExpression = SyntaxFactory.LiteralExpression( + boolVal ? SyntaxKind.TrueLiteralExpression : SyntaxKind.FalseLiteralExpression); + } + else if (value is string stringVal) + { + newExpression = SyntaxFactory.LiteralExpression( + SyntaxKind.StringLiteralExpression, + SyntaxFactory.Literal(stringVal)); + } + else if (value is char charVal) + { + newExpression = SyntaxFactory.LiteralExpression( + SyntaxKind.CharacterLiteralExpression, + SyntaxFactory.Literal(charVal)); + } + + if (newExpression != null) + { + return SyntaxFactory.EqualsValueClause(newExpression) + .WithLeadingTrivia(oldInitializer.GetLeadingTrivia()) + .WithTrailingTrivia(oldInitializer.GetTrailingTrivia()); + } + + return null; + } } /// diff --git a/KitX Clients/KitX Dashboard b/KitX Clients/KitX Dashboard index 3c641a69..98ea5706 160000 --- a/KitX Clients/KitX Dashboard +++ b/KitX Clients/KitX Dashboard @@ -1 +1 @@ -Subproject commit 3c641a695ca684844469582e9f7b848e611cbb33 +Subproject commit 98ea5706f421530f631eea86ac34289663480ba5 diff --git a/KitX Standard b/KitX Standard index 05cc0009..bc08809a 160000 --- a/KitX Standard +++ b/KitX Standard @@ -1 +1 @@ -Subproject commit 05cc000900c0dd6ba6a0a451084f28e9a19d93e5 +Subproject commit bc08809aa9e37c84a289193eb32237c9c7e48683 From d13142d3a4329c96a0abdd01bd2bd36bf76d9d59 Mon Sep 17 00:00:00 2001 From: StarInk Date: Thu, 19 Mar 2026 07:01:47 +0100 Subject: [PATCH 022/110] =?UTF-8?q?=F0=9F=8E=87=20=F0=9F=A7=A9=20Style,=20?= =?UTF-8?q?Refactor(Dashboard):=20=E4=BC=98=E5=8C=96=E5=88=9D=E5=A7=8B?= =?UTF-8?q?=E5=8C=96=E6=B5=81=E7=A8=8B=E3=80=81DI=E5=AE=B9=E5=99=A8?= =?UTF-8?q?=E6=B3=A8=E5=86=8C=E6=9C=BA=E5=88=B6=EF=BC=9B=E7=BB=9F=E4=B8=80?= =?UTF-8?q?ITaskService=E7=9A=84=E4=BD=BF=E7=94=A8=E5=8E=9F=E5=88=99?= =?UTF-8?q?=EF=BC=9B=E9=87=8D=E5=91=BD=E5=90=8D=E5=91=BD=E5=90=8D=E7=A9=BA?= =?UTF-8?q?=E9=97=B4Core.Task->Core.Tasks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../KitX.Core/Activity/ActivityManager.cs | 2 +- .../KitX.Core/Configuration/ConfigManager.cs | 4 +- .../DI/CoreServiceCollectionExtensions.cs | 2 +- .../KitX.Core/{Task => Tasks}/TasksManager.cs | 58 ++++++++++++++++++- KitX Clients/KitX Dashboard | 2 +- KitX Standard | 2 +- 6 files changed, 63 insertions(+), 7 deletions(-) rename KitX Clients/KitX Core/KitX.Core/{Task => Tasks}/TasksManager.cs (64%) diff --git a/KitX Clients/KitX Core/KitX.Core/Activity/ActivityManager.cs b/KitX Clients/KitX Core/KitX.Core/Activity/ActivityManager.cs index a1334ce9..f29ea655 100644 --- a/KitX Clients/KitX Core/KitX.Core/Activity/ActivityManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/Activity/ActivityManager.cs @@ -8,7 +8,7 @@ using KitX.Core.Contract.Activity; using KitX.Core.Event; using LiteDB; -using KitX.Core.Task; +using KitX.Core.Tasks; namespace KitX.Core.Activity; diff --git a/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigManager.cs b/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigManager.cs index 1e3e3f09..c5b8d839 100644 --- a/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigManager.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -19,7 +19,7 @@ public class ConfigManager : IConfigService, IDisposable /// /// Gets the singleton instance /// - internal static ConfigManager Instance => _instance ??= new(); + public static ConfigManager Instance => _instance ??= new(); private string? _configLocation; diff --git a/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs b/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs index b72fc0d8..a338216f 100644 --- a/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs +++ b/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs @@ -20,7 +20,7 @@ using KitX.Core.Plugin; using KitX.Core.Security; using KitX.Core.Statistics; -using KitX.Core.Task; +using KitX.Core.Tasks; using KitX.Core.Workflow; using KitX.Core.Event; using Serilog; diff --git a/KitX Clients/KitX Core/KitX.Core/Task/TasksManager.cs b/KitX Clients/KitX Core/KitX.Core/Tasks/TasksManager.cs similarity index 64% rename from KitX Clients/KitX Core/KitX.Core/Task/TasksManager.cs rename to KitX Clients/KitX Core/KitX.Core/Tasks/TasksManager.cs index 913b51e9..5300f349 100644 --- a/KitX Clients/KitX Core/KitX.Core/Task/TasksManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/Tasks/TasksManager.cs @@ -1,10 +1,11 @@ using System; +using System.Threading; using System.Threading.Tasks; using KitX.Core.Contract.Tasks; using Serilog; using CTask = System.Threading.Tasks.Task; -namespace KitX.Core.Task; +namespace KitX.Core.Tasks; /// /// Tasks manager for background task management @@ -84,6 +85,18 @@ public CTask RunTaskAsync(Func task, string? taskName = null) return RunTaskAsync(task, taskName ?? nameof(Action), prompt: ">>> ", catchException: true, logIt: true); } + /// + /// Runs an asynchronous task with cancellation support + /// + /// The task to run + /// Cancellation token + /// Optional task name + /// Task representing the async operation + public CTask RunTaskAsync(Func task, CancellationToken cancellationToken, string? taskName = null) + { + return RunTaskAsync(task, taskName ?? nameof(Action), cancellationToken, prompt: ">>> ", catchException: true, logIt: true); + } + /// /// Runs an asynchronous task with detailed configuration /// @@ -124,4 +137,47 @@ public async CTask RunTaskAsync( if (logIt) Log.Information($"{prompt}Task `{name}` done."); } + + /// + /// Runs an asynchronous task with detailed configuration and cancellation support + /// + /// The task to run + /// Task name + /// Cancellation token + /// Log prompt prefix + /// Whether to catch exceptions + /// Whether to log the task + /// Task representing the async operation + public async CTask RunTaskAsync( + Func task, + string name, + CancellationToken cancellationToken, + string prompt = ">>> ", + bool catchException = true, + bool logIt = true + ) + { + if (logIt) + Log.Information($"{prompt}Task `{name}` began."); + + if (catchException) + { + try + { + await CTask.Run(task, cancellationToken); + } + catch (Exception e) + { + if (logIt) + Log.Error(e, $"{prompt}Task `{name}` failed: {e.Message}"); + } + } + else + { + await CTask.Run(task, cancellationToken); + } + + if (logIt) + Log.Information($"{prompt}Task `{name}` done."); + } } diff --git a/KitX Clients/KitX Dashboard b/KitX Clients/KitX Dashboard index 98ea5706..badd54ff 160000 --- a/KitX Clients/KitX Dashboard +++ b/KitX Clients/KitX Dashboard @@ -1 +1 @@ -Subproject commit 98ea5706f421530f631eea86ac34289663480ba5 +Subproject commit badd54ff3c5f82a3b6b3c57be6da5c93f64d679e diff --git a/KitX Standard b/KitX Standard index bc08809a..13ae5c66 160000 --- a/KitX Standard +++ b/KitX Standard @@ -1 +1 @@ -Subproject commit bc08809aa9e37c84a289193eb32237c9c7e48683 +Subproject commit 13ae5c66502b25833b18fb474380b7fb6a39a3bb From cfadee6522d70134364fae71ad350e0f418432b0 Mon Sep 17 00:00:00 2001 From: StarInk Date: Thu, 19 Mar 2026 11:08:37 +0100 Subject: [PATCH 023/110] =?UTF-8?q?=F0=9F=92=BE=20Feat(Workflow):=20?= =?UTF-8?q?=E4=B8=BAblock=20script=E7=89=B9=E6=80=A7=E5=81=9A=E5=87=86?= =?UTF-8?q?=E5=A4=87=EF=BC=88=E5=AE=9E=E7=8E=B0=E7=B1=BB=EF=BC=8C=E6=9C=AA?= =?UTF-8?q?=E5=BA=94=E7=94=A8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DI/CoreServiceCollectionExtensions.cs | 23 + .../BlockScripting/BlockScopeManager.cs | 197 ++++++ .../BlockScripting/BlockScriptExecutor.cs | 495 +++++++++++++++ .../BlockScripting/BlockScriptParser.cs | 578 ++++++++++++++++++ .../BlockScripting/BuiltInFunctions.cs | 192 ++++++ .../Workflow/WorkflowScriptService.cs | 92 +++ KitX Standard | 2 +- 7 files changed, 1578 insertions(+), 1 deletion(-) create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScopeManager.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutor.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptParser.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltInFunctions.cs diff --git a/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs b/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs index a338216f..49b83637 100644 --- a/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs +++ b/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs @@ -22,6 +22,7 @@ using KitX.Core.Statistics; using KitX.Core.Tasks; using KitX.Core.Workflow; +using KitX.Core.Workflow.BlockScripting; using KitX.Core.Event; using Serilog; @@ -173,6 +174,28 @@ public static IServiceCollection AddCoreServices(this IServiceCollection service return service; }); + // Block Script Services + Log.Information("Registering IBlockScriptParser..."); + services.AddSingleton(provider => + { + var service = new KitX.Core.Workflow.BlockScripting.BlockScriptParser(); + return service; + }); + + Log.Information("Registering IBlockScriptExecutor..."); + services.AddSingleton(provider => + { + var service = new KitX.Core.Workflow.BlockScripting.BlockScriptExecutor(); + return service; + }); + + Log.Information("Registering IBlockScopeManager..."); + services.AddSingleton(provider => + { + var service = new KitX.Core.Workflow.BlockScripting.BlockScopeManager(); + return service; + }); + Log.Information("AddCoreServices completed."); return services; } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScopeManager.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScopeManager.cs new file mode 100644 index 00000000..ac07b79a --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScopeManager.cs @@ -0,0 +1,197 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using KitX.Core.Contract.Workflow; + +namespace KitX.Core.Workflow.BlockScripting; + +/// +/// Block scope manager implementation - manages variable scoping for block scripts +/// +public class BlockScopeManager : IBlockScopeManager +{ + private readonly BlockScope _globalScope; + private readonly Dictionary _localScopes = new(); + + /// + /// Creates a new block scope manager + /// + public BlockScopeManager() + { + _globalScope = new BlockScope("Global", isGlobal: true); + } + + /// + /// Gets the global (ConstBlock) scope + /// + public IBlockScope GlobalScope => _globalScope; + + /// + /// Creates a new local scope for a block + /// + public IBlockScope CreateLocalScope(string blockName) + { + if (_localScopes.TryGetValue(blockName, out var existing)) + { + return existing; + } + + var scope = new BlockScope(blockName, isGlobal: false); + _localScopes[blockName] = scope; + return scope; + } + + /// + /// Resolves a variable name to its value (searches local then global) + /// + public object? ResolveVariable(string name) + { + // Search local scopes in reverse order (most recent first) + foreach (var scope in _localScopes.Values.Reverse()) + { + if (scope.HasVariable(name)) + return scope.GetVariable(name); + } + + // Fall back to global scope + return _globalScope.HasVariable(name) ? _globalScope.GetVariable(name) : null; + } + + /// + /// Sets a variable value in the appropriate scope + /// + public void SetVariable(string name, object? value, bool global = false) + { + if (global) + { + _globalScope.SetVariable(name, value); + } + else + { + // Find the most recent local scope and set there + var lastScope = _localScopes.Values.LastOrDefault(); + if (lastScope != null) + { + lastScope.SetVariable(name, value); + } + else + { + // No local scope exists, set in global + _globalScope.SetVariable(name, value); + } + } + } + + /// + /// Checks if a variable exists in any scope + /// + public bool HasVariable(string name) + { + foreach (var scope in _localScopes.Values.Reverse()) + { + if (scope.HasVariable(name)) + return true; + } + return _globalScope.HasVariable(name); + } + + /// + /// Clears all local scopes (called between executions) + /// + public void ClearLocalScopes() + { + _localScopes.Clear(); + } + + /// + /// Initializes the global scope with variables from ConstBlock and PubVarBlock + /// + public void InitializeGlobalScope(BlockScript script) + { + // Initialize from ConstBlock + if (script.ConstBlock != null) + { + foreach (var varDecl in script.ConstBlock.Variables) + { + _globalScope.SetVariable(varDecl.Name, varDecl.DefaultValue); + } + } + + // Initialize from PubVarBlock + if (script.PubVarBlock != null) + { + foreach (var varDecl in script.PubVarBlock.Variables) + { + _globalScope.SetVariable(varDecl.Name, varDecl.DefaultValue); + } + } + } + + /// + /// Gets a local scope by block name + /// + public IBlockScope? GetLocalScope(string blockName) + { + return _localScopes.TryGetValue(blockName, out var scope) ? scope : null; + } +} + +/// +/// Variable scope implementation +/// +public class BlockScope : IBlockScope +{ + private readonly Dictionary _variables = new(); + private readonly bool _isGlobal; + + /// + /// Creates a new block scope + /// + public BlockScope(string blockName, bool isGlobal) + { + BlockName = blockName; + _isGlobal = isGlobal; + } + + /// + /// Name of the block this scope belongs to + /// + public string BlockName { get; } + + /// + /// Whether this is the global scope + /// + public bool IsGlobal => _isGlobal; + + /// + /// Gets a variable value + /// + public object? GetVariable(string name) + { + return _variables.TryGetValue(name, out var value) ? value : null; + } + + /// + /// Sets a variable value + /// + public void SetVariable(string name, object? value) + { + _variables[name] = value; + } + + /// + /// Checks if a variable exists in this scope + /// + public bool HasVariable(string name) + { + return _variables.ContainsKey(name); + } + + /// + /// Gets all variables in this scope + /// + public Dictionary GetAllVariables() + { + return new Dictionary(_variables); + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutor.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutor.cs new file mode 100644 index 00000000..f13945a2 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutor.cs @@ -0,0 +1,495 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.CSharp.Scripting; +using Microsoft.CodeAnalysis.Scripting; +using KitX.Core.Contract.Workflow; +using KitX.Core.Workflow; +using Serilog; + +namespace KitX.Core.Workflow.BlockScripting; + +/// +/// Block script executor - executes parsed block scripts using a call stack model +/// +public class BlockScriptExecutor : IBlockScriptExecutor +{ + private readonly BlockScopeManager _scopeManager; + private readonly Stopwatch _stopwatch = new(); + + /// + /// Creates a new block script executor + /// + public BlockScriptExecutor() + { + _scopeManager = new BlockScopeManager(); + } + + /// + /// Creates a new block script executor with existing scope manager + /// + public BlockScriptExecutor(BlockScopeManager scopeManager) + { + _scopeManager = scopeManager; + } + + /// + /// Executes a block script + /// + public async Task ExecuteAsync( + BlockScript script, + Dictionary? parameters = null, + CancellationToken cancellationToken = default) + { + _stopwatch.Restart(); + var output = new List(); + var executedBlockCount = 0; + + try + { + // Clear previous execution state + _scopeManager.ClearLocalScopes(); + BuiltInFunctions.ClearOutput(); + + // Initialize global scope with ConstBlock and PubVarBlock + _scopeManager.InitializeGlobalScope(script); + + // Import parameters into global scope + if (parameters != null) + { + foreach (var param in parameters) + { + _scopeManager.SetVariable(param.Key, param.Value, global: true); + } + } + + // Build script globals with all required members + var globals = new BlockScriptExecutionGlobals(_scopeManager, output); + + // 1. Execute ConstBlock and PubVarBlock (global variables) + if (script.ConstBlock != null) + { + executedBlockCount++; + await ExecuteBlockAsync(script.ConstBlock, globals, cancellationToken); + } + + if (script.PubVarBlock != null) + { + executedBlockCount++; + await ExecuteBlockAsync(script.PubVarBlock, globals, cancellationToken); + } + + // 2. Execute MainBlock + if (script.MainBlock == null) + { + return new BlockScriptExecutionResult + { + IsSuccess = false, + ErrorMessage = "No MainBlock found in script", + ExecutedBlockCount = executedBlockCount, + ExecutionTimeMs = _stopwatch.ElapsedMilliseconds, + Output = output + }; + } + + executedBlockCount++; + await ExecuteBlockAsync(script.MainBlock, globals, cancellationToken); + + _stopwatch.Stop(); + + return new BlockScriptExecutionResult + { + IsSuccess = true, + ExecutedBlockCount = executedBlockCount, + ExecutionTimeMs = _stopwatch.ElapsedMilliseconds, + Output = output + }; + } + catch (ReturnException returnEx) + { + _stopwatch.Stop(); + return new BlockScriptExecutionResult + { + IsSuccess = true, + ReturnValue = returnEx.Value, + ExecutedBlockCount = executedBlockCount, + ExecutionTimeMs = _stopwatch.ElapsedMilliseconds, + Output = output + }; + } + catch (BranchException branchEx) + { + _stopwatch.Stop(); + return new BlockScriptExecutionResult + { + IsSuccess = false, + ErrorMessage = $"Unresolved branch to '{branchEx.TargetBlock}' at end of block execution", + ExecutedBlockCount = executedBlockCount, + ExecutionTimeMs = _stopwatch.ElapsedMilliseconds, + Output = output + }; + } + catch (Exception ex) + { + _stopwatch.Stop(); + Log.Error(ex, "[BlockScriptExecutor] Error executing block script"); + return new BlockScriptExecutionResult + { + IsSuccess = false, + ErrorMessage = $"Execution error: {ex.Message}", + ExecutedBlockCount = executedBlockCount, + ExecutionTimeMs = _stopwatch.ElapsedMilliseconds, + Output = output + }; + } + } + + /// + /// Executes a specific block by name + /// + public async Task ExecuteBlockAsync( + BlockScript script, + string blockName, + Dictionary? parameters = null, + CancellationToken cancellationToken = default) + { + if (!script.NamedBlocks.TryGetValue(blockName, out var block)) + { + return new BlockScriptExecutionResult + { + IsSuccess = false, + ErrorMessage = $"Block '{blockName}' not found" + }; + } + + var globals = new BlockScriptExecutionGlobals(_scopeManager, new List()); + await ExecuteBlockAsync(block, globals, cancellationToken); + + return new BlockScriptExecutionResult + { + IsSuccess = true, + ExecutedBlockCount = 1 + }; + } + + /// + /// Validates a block script + /// + public BlockScriptValidationResult Validate(BlockScript script) + { + var result = new BlockScriptValidationResult { IsValid = true }; + + if (script.MainBlock == null) + { + result.AddError("Script must have a MainBlock"); + result.IsValid = false; + } + + // Check that all branch targets exist + var allBlockNames = script.NamedBlocks.Keys.ToHashSet(); + if (script.MainBlock != null) + allBlockNames.Add(script.MainBlock.Name); + + foreach (var block in script.AllBlocks) + { + foreach (var statement in block.Statements) + { + if (statement is FlowControlStatement flow) + { + if (flow.ControlType == FlowControlType.Branch) + { + if (!string.IsNullOrEmpty(flow.TrueBlockName) && + !allBlockNames.Contains(flow.TrueBlockName)) + { + result.AddError($"Block '{flow.TrueBlockName}' referenced in Branch at line {flow.LineNumber} does not exist"); + } + if (!string.IsNullOrEmpty(flow.FalseBlockName) && + !allBlockNames.Contains(flow.FalseBlockName)) + { + result.AddError($"Block '{flow.FalseBlockName}' referenced in Branch at line {flow.LineNumber} does not exist"); + } + } + else if (flow.ControlType == FlowControlType.Loop) + { + if (!string.IsNullOrEmpty(flow.LoopBlockName) && + !allBlockNames.Contains(flow.LoopBlockName)) + { + result.AddError($"Block '{flow.LoopBlockName}' referenced in Loop at line {flow.LineNumber} does not exist"); + } + } + } + } + } + + if (result.Errors.Count > 0) + result.IsValid = false; + + return result; + } + + /// + /// Executes a single block + /// + private async Task ExecuteBlockAsync( + BlockDefinition block, + BlockScriptExecutionGlobals globals, + CancellationToken cancellationToken) + { + // Create local scope for this block + var localScope = _scopeManager.CreateLocalScope(block.Name); + + // Declare local variables in scope + foreach (var varDecl in block.Variables) + { + localScope.SetVariable(varDecl.Name, varDecl.DefaultValue); + } + + // Execute statements in order + for (int i = 0; i < block.Statements.Count; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + + var statement = block.Statements[i]; + await ExecuteStatementAsync(statement, globals, cancellationToken); + } + } + + /// + /// Executes a single statement + /// + private async Task ExecuteStatementAsync( + BlockStatement statement, + BlockScriptExecutionGlobals globals, + CancellationToken cancellationToken) + { + try + { + if (statement is ExpressionStatement exprStmt) + { + await ExecuteExpressionAsync(exprStmt.Expression, globals, cancellationToken); + } + else if (statement is VariableDeclarationStatement varStmt) + { + // Variable declaration is handled in ExecuteBlockAsync + // For now, we re-evaluate to set the value + if (!string.IsNullOrEmpty(varStmt.Declaration.InitialValueExpression)) + { + await EvaluateExpressionAsync(varStmt.Declaration.InitialValueExpression, globals, cancellationToken); + } + } + else if (statement is FlowControlStatement flowStmt) + { + await ExecuteFlowControlAsync(flowStmt, globals, cancellationToken); + } + } + catch (BranchException ex) + { + // Rethrow to be handled by execution engine + throw; + } + catch (ReturnException ex) + { + // Rethrow to be handled by execution engine + throw; + } + catch (Exception ex) + { + Log.Error(ex, "[BlockScriptExecutor] Error executing statement at line {Line}", statement.LineNumber); + throw; + } + } + + /// + /// Executes an expression statement + /// + private async Task ExecuteExpressionAsync( + string expression, + BlockScriptExecutionGlobals globals, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(expression)) + return; + + // Check if it's an assignment + if (expression.Contains('=') && !expression.Contains("==") && !expression.Contains("!=")) + { + // This is an assignment - execute as script + await EvaluateExpressionAsync(expression, globals, cancellationToken); + } + else + { + // This is a function call or other expression + await EvaluateExpressionAsync(expression, globals, cancellationToken); + } + } + + /// + /// Evaluates an expression and returns the result + /// + private async Task EvaluateExpressionAsync( + string expression, + BlockScriptExecutionGlobals globals, + CancellationToken cancellationToken) + { + try + { + // Build a complete statement for execution + var code = WrapExpressionAsStatement(expression); + + var result = await CSharpScript.EvaluateAsync( + code, + ScriptOptions.Default + .WithReferences(typeof(BuiltInFunctions).Assembly) + .WithImports( + "System", + "KitX.Core.Workflow.BlockScripting" + ), + globals: globals, + cancellationToken: cancellationToken); + + return result; + } + catch (Exception ex) + { + Log.Warning(ex, "[BlockScriptExecutor] Error evaluating expression: {Expression}", expression); + throw; + } + } + + /// + /// Wraps an expression as a statement for evaluation + /// + private string WrapExpressionAsStatement(string expression) + { + expression = expression.Trim(); + if (expression.EndsWith(';')) + return expression; + return expression + ";"; + } + + /// + /// Executes flow control statement + /// + private async Task ExecuteFlowControlAsync( + FlowControlStatement statement, + BlockScriptExecutionGlobals globals, + CancellationToken cancellationToken) + { + switch (statement.ControlType) + { + case FlowControlType.Branch: + // Evaluate condition and throw BranchException + if (!string.IsNullOrEmpty(statement.ConditionExpression)) + { + var condition = await EvaluateExpressionAsync(statement.ConditionExpression, globals, cancellationToken); + if (condition is bool boolCondition) + { + var targetBlock = boolCondition ? statement.TrueBlockName : statement.FalseBlockName; + if (!string.IsNullOrEmpty(targetBlock)) + { + throw new BranchException(targetBlock); + } + } + } + break; + + case FlowControlType.Loop: + // Check condition and throw BranchException if true + if (!string.IsNullOrEmpty(statement.ConditionExpression)) + { + var condition = await EvaluateExpressionAsync(statement.ConditionExpression, globals, cancellationToken); + if (condition is bool boolCondition && boolCondition) + { + if (!string.IsNullOrEmpty(statement.LoopBlockName)) + { + throw new BranchException(statement.LoopBlockName); + } + } + } + break; + + case FlowControlType.Return: + if (!string.IsNullOrEmpty(statement.ConditionExpression)) + { + var returnValue = await EvaluateExpressionAsync(statement.ConditionExpression, globals, cancellationToken); + throw new ReturnException(returnValue); + } + throw new ReturnException(null); + + case FlowControlType.Break: + throw new BreakException(); + } + } +} + +/// +/// Script globals for block script execution - provides access to built-in functions and collects output +/// +public class BlockScriptExecutionGlobals +{ + private readonly BlockScopeManager _scopeManager; + private readonly List _output; + + /// + /// Creates script globals + /// + public BlockScriptExecutionGlobals(BlockScopeManager scopeManager, List output) + { + _scopeManager = scopeManager; + _output = output; + } + + /// + /// Gets a variable value + /// + public object? GetVariable(string name) + { + return _scopeManager.ResolveVariable(name); + } + + /// + /// Sets a variable value + /// + public void SetVariable(string name, object? value) + { + _scopeManager.SetVariable(name, value, global: false); + } + + /// + /// Condition branch + /// + public void Branch(bool condition, string trueBlock, string falseBlock) + { + BuiltInFunctions.Branch(condition, trueBlock, falseBlock); + } + + /// + /// Loop while condition is true + /// + public void Loop(bool condition, string loopBlock) + { + BuiltInFunctions.Loop(condition, loopBlock); + } + + /// + /// Print a value + /// + public void Print(object? value) + { + var str = value?.ToString() ?? "null"; + _output.Add(str); + WorkflowOutput.WriteLine(value); + } + + /// + /// Pause execution + /// + public void Pause(int milliseconds) + { + BuiltInFunctions.Pause(milliseconds); + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptParser.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptParser.cs new file mode 100644 index 00000000..24049d68 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptParser.cs @@ -0,0 +1,578 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using KitX.Core.Contract.Workflow; +using Serilog; + +namespace KitX.Core.Workflow.BlockScripting; + +/// +/// Block script parser implementation using Roslyn CSharp syntax analysis +/// +public class BlockScriptParser : IBlockScriptParser +{ + /// + /// Parses a block-based script from source code + /// + public BlockScriptParseResult Parse(string sourceCode) + { + if (string.IsNullOrWhiteSpace(sourceCode)) + { + return new BlockScriptParseResult + { + IsSuccess = false, + ErrorMessage = "Source code is empty", + ErrorLine = 0 + }; + } + + try + { + // 1. Parse to syntax tree + var syntaxTree = CSharpSyntaxTree.ParseText(sourceCode); + var root = syntaxTree.GetRoot(); + + // 2. Validate prohibited syntax + var validator = new BlockScriptValidator(); + validator.Visit(root); + + if (!validator.IsValid) + { + return new BlockScriptParseResult + { + IsSuccess = false, + ErrorMessage = validator.ErrorMessage, + ErrorLine = validator.ErrorLine + }; + } + + // 3. Extract blocks + var extractor = new BlockExtractor(); + extractor.Visit(root); + + // 4. Build and return block script + var script = extractor.BuildBlockScript(); + script.SourceCode = sourceCode; + + return new BlockScriptParseResult + { + IsSuccess = true, + Script = script + }; + } + catch (Exception ex) + { + Log.Error(ex, "[BlockScriptParser] Error parsing block script"); + return new BlockScriptParseResult + { + IsSuccess = false, + ErrorMessage = $"Parse error: {ex.Message}", + ErrorLine = 0 + }; + } + } + + /// + /// Parses a block-based script from source code asynchronously + /// + public Task ParseAsync(string sourceCode) + { + return Task.FromResult(Parse(sourceCode)); + } + + /// + /// Validates block script syntax and structure + /// + public BlockScriptValidationResult Validate(string sourceCode) + { + var result = new BlockScriptValidationResult { IsValid = true }; + + if (string.IsNullOrWhiteSpace(sourceCode)) + { + result.IsValid = false; + result.AddError("Source code is empty"); + return result; + } + + try + { + var syntaxTree = CSharpSyntaxTree.ParseText(sourceCode); + var root = syntaxTree.GetRoot(); + + var validator = new BlockScriptValidator(); + validator.Visit(root); + + if (!validator.IsValid) + { + result.IsValid = false; + result.AddError(validator.ErrorMessage); + } + + // Add warnings + foreach (var warning in validator.Warnings) + { + result.AddWarning(warning); + } + } + catch (Exception ex) + { + result.IsValid = false; + result.AddError($"Validation error: {ex.Message}"); + } + + return result; + } +} + +/// +/// Block script syntax validator - validates prohibited syntax in each block type +/// +internal class BlockScriptValidator : CSharpSyntaxWalker +{ + public bool IsValid { get; private set; } = true; + public string ErrorMessage { get; private set; } = string.Empty; + public int ErrorLine { get; private set; } + public List Warnings { get; } = []; + + private BlockType _currentBlockType = BlockType.NamedBlock; + private string _currentBlockName = string.Empty; + private bool _inConstBlock = false; + private bool _inPubVarBlock = false; + + public override void VisitAttribute(AttributeSyntax node) + { + var attrName = node.Name.ToString(); + + if (attrName.Contains("ConstBlock")) + { + _inConstBlock = true; + _inPubVarBlock = false; + _currentBlockType = BlockType.ConstBlock; + } + else if (attrName.Contains("PubVarBlock")) + { + _inConstBlock = false; + _inPubVarBlock = true; + _currentBlockType = BlockType.PubVarBlock; + } + else if (attrName.Contains("MainBlock")) + { + _inConstBlock = false; + _inPubVarBlock = false; + _currentBlockType = BlockType.MainBlock; + } + else if (attrName.Contains("Block")) + { + _inConstBlock = false; + _inPubVarBlock = false; + _currentBlockType = BlockType.NamedBlock; + + // Extract block name from [Block="Name"] + var nameArg = node.ArgumentList?.Arguments.FirstOrDefault(); + if (nameArg != null) + { + _currentBlockName = nameArg.Expression.GetText().ToString().Trim('"'); + } + } + + base.VisitAttribute(node); + } + + public override void VisitGlobalStatement(GlobalStatementSyntax node) + { + ValidateStatement(node.Statement); + base.VisitGlobalStatement(node); + } + + private void ValidateStatement(StatementSyntax statement) + { + // For ConstBlock: only variable declarations allowed + if (_inConstBlock) + { + if (statement is not LocalDeclarationStatementSyntax) + { + Fail($"ConstBlock only allows variable declarations", statement); + return; + } + } + // For PubVarBlock: only variable declarations and assignments allowed + else if (_inPubVarBlock) + { + if (statement is LocalDeclarationStatementSyntax) + { + // OK - variable declaration + } + else if (statement is ExpressionStatementSyntax exprStmt) + { + // Check if it's an assignment + if (exprStmt.Expression is not AssignmentExpressionSyntax) + { + Fail($"PubVarBlock only allows variable declarations and assignments", statement); + return; + } + } + else + { + Fail($"PubVarBlock only allows variable declarations and assignments", statement); + return; + } + } + // For MainBlock and NamedBlock: validate allowed syntax + else + { + ValidateMainBlockStatement(statement); + } + } + + private void ValidateMainBlockStatement(StatementSyntax statement) + { + // Allowed: LocalDeclaration, ExpressionStatement, ReturnStatement + if (statement is LocalDeclarationStatementSyntax) + { + // OK - variable declaration + } + else if (statement is ExpressionStatementSyntax) + { + // OK - expression statement (function call, assignment, etc.) + } + else if (statement is ReturnStatementSyntax) + { + // OK - return statement + } + else if (statement is IfStatementSyntax) + { + Fail("if statements are not allowed. Use Branch() function instead.", statement); + } + else if (statement is WhileStatementSyntax) + { + Fail("while statements are not allowed. Use Loop() function instead.", statement); + } + else if (statement is ForStatementSyntax) + { + Fail("for statements are not allowed.", statement); + } + else if (statement is ForEachStatementSyntax) + { + Fail("foreach statements are not allowed.", statement); + } + else if (statement is SwitchStatementSyntax) + { + Fail("switch statements are not allowed.", statement); + } + else if (statement is TryStatementSyntax) + { + Fail("try-catch statements are not allowed.", statement); + } + else if (statement is ThrowStatementSyntax) + { + Fail("throw statements are not allowed.", statement); + } + else if (statement is UsingStatementSyntax) + { + Fail("using statements are not allowed.", statement); + } + else if (statement is LockStatementSyntax) + { + Fail("lock statements are not allowed.", statement); + } + else if (statement is YieldStatementSyntax) + { + Fail("yield statements are not allowed.", statement); + } + else if (statement is GotoStatementSyntax) + { + Fail("goto statements are not allowed.", statement); + } + else + { + // Allow other statement types but warn + Warnings.Add($"Unexpected statement type at line {GetLineNumber(statement)}: {statement.Kind()}"); + } + } + + private void Fail(string message, StatementSyntax statement) + { + IsValid = false; + ErrorMessage = $"[{_currentBlockType}] {message}"; + ErrorLine = GetLineNumber(statement); + } + + private int GetLineNumber(SyntaxNode node) + { + var location = node.GetLocation(); + var lineSpan = location.GetLineSpan(); + return lineSpan.StartLinePosition.Line + 1; + } +} + +/// +/// Block extractor - extracts blocks from syntax tree +/// +internal class BlockExtractor : CSharpSyntaxWalker +{ + private BlockDefinition? _currentBlock; + private BlockScript _script = new(); + private BlockType _currentBlockType = BlockType.NamedBlock; + private string _currentBlockName = string.Empty; + private bool _inConstBlock = false; + private bool _inPubVarBlock = false; + + public BlockScript BuildBlockScript() => _script; + + public override void VisitAttribute(AttributeSyntax node) + { + var attrName = node.Name.ToString(); + + if (attrName.Contains("ConstBlock")) + { + StartBlock(BlockType.ConstBlock, "ConstBlock"); + _inConstBlock = true; + _inPubVarBlock = false; + } + else if (attrName.Contains("PubVarBlock")) + { + StartBlock(BlockType.PubVarBlock, "PubVarBlock"); + _inConstBlock = false; + _inPubVarBlock = true; + } + else if (attrName.Contains("MainBlock")) + { + StartBlock(BlockType.MainBlock, "MainBlock"); + _inConstBlock = false; + _inPubVarBlock = false; + } + else if (attrName.Contains("Block")) + { + // Extract block name from [Block="Name"] + var nameArg = node.ArgumentList?.Arguments.FirstOrDefault(); + var blockName = "NamedBlock"; + if (nameArg != null) + { + blockName = nameArg.Expression.GetText().ToString().Trim('"'); + } + StartBlock(BlockType.NamedBlock, blockName); + _inConstBlock = false; + _inPubVarBlock = false; + } + + base.VisitAttribute(node); + } + + public override void VisitGlobalStatement(GlobalStatementSyntax node) + { + if (_currentBlock == null) + { + // Statements outside of any block - add to MainBlock if exists, otherwise create one + if (_script.MainBlock == null) + { + _script.MainBlock = new BlockDefinition + { + Type = BlockType.MainBlock, + Name = "MainBlock", + LineNumber = GetLineNumber(node) + }; + _script.AllBlocks.Add(_script.MainBlock); + } + _currentBlock = _script.MainBlock; + } + + ExtractStatement(node.Statement, _currentBlock); + base.VisitGlobalStatement(node); + } + + private void StartBlock(BlockType type, string name) + { + _currentBlockType = type; + _currentBlockName = name; + + _currentBlock = new BlockDefinition + { + Type = type, + Name = name, + LineNumber = GetLineNumber(GetCurrentAttributeSyntax()) + }; + + switch (type) + { + case BlockType.ConstBlock: + _script.ConstBlock = _currentBlock; + break; + case BlockType.PubVarBlock: + _script.PubVarBlock = _currentBlock; + break; + case BlockType.MainBlock: + _script.MainBlock = _currentBlock; + break; + case BlockType.NamedBlock: + _script.NamedBlocks[name] = _currentBlock; + break; + } + + _script.AllBlocks.Add(_currentBlock); + } + + private AttributeSyntax? _lastAttribute; + + private AttributeSyntax? GetCurrentAttributeSyntax() + { + return _lastAttribute; + } + + public override void VisitAttributeList(AttributeListSyntax node) + { + _lastAttribute = node.Attributes.FirstOrDefault(); + base.VisitAttributeList(node); + } + + private void ExtractStatement(StatementSyntax statement, BlockDefinition block) + { + var lineNumber = GetLineNumber(statement); + var sourceCode = statement.ToFullString(); + + if (statement is LocalDeclarationStatementSyntax localDecl) + { + foreach (var declarator in localDecl.Declaration.Variables) + { + var varDecl = new Contract.Workflow.VariableDeclaration + { + Name = declarator.Identifier.Text, + Type = localDecl.Declaration.Type.ToString(), + InitialValueExpression = declarator.Initializer?.Value?.ToString() + }; + + // Pre-evaluate constant values + if (_inConstBlock && declarator.Initializer?.Value is LiteralExpressionSyntax literal) + { + varDecl.DefaultValue = GetLiteralValue(literal); + } + + block.Variables.Add(varDecl); + } + + block.Statements.Add(new Contract.Workflow.VariableDeclarationStatement + { + LineNumber = lineNumber, + SourceCode = sourceCode, + Declaration = block.Variables.Last() + }); + } + else if (statement is ExpressionStatementSyntax exprStmt) + { + var exprText = exprStmt.Expression.ToString(); + + // Check if this is a Branch/Loop call + if (exprStmt.Expression is InvocationExpressionSyntax invoke) + { + var methodName = GetMethodName(invoke); + if (methodName == "Branch") + { + ExtractBranchStatement(invoke, block, lineNumber, sourceCode); + return; + } + else if (methodName == "Loop") + { + ExtractLoopStatement(invoke, block, lineNumber, sourceCode); + return; + } + } + + block.Statements.Add(new ExpressionStatement + { + LineNumber = lineNumber, + SourceCode = sourceCode, + Expression = exprText + }); + } + else if (statement is ReturnStatementSyntax returnStmt) + { + block.Statements.Add(new FlowControlStatement + { + LineNumber = lineNumber, + SourceCode = sourceCode, + ControlType = FlowControlType.Return, + ConditionExpression = returnStmt.Expression?.ToString() ?? string.Empty + }); + } + else + { + // Generic statement - store as expression + block.Statements.Add(new ExpressionStatement + { + LineNumber = lineNumber, + SourceCode = sourceCode, + Expression = sourceCode + }); + } + } + + private void ExtractBranchStatement(InvocationExpressionSyntax invoke, BlockDefinition block, int lineNumber, string sourceCode) + { + var args = invoke.ArgumentList.Arguments; + var flowControl = new FlowControlStatement + { + LineNumber = lineNumber, + SourceCode = sourceCode, + ControlType = FlowControlType.Branch + }; + + if (args.Count >= 1) + flowControl.ConditionExpression = args[0].Expression.ToString(); + if (args.Count >= 2) + flowControl.TrueBlockName = GetStringLiteral(args[1].Expression); + if (args.Count >= 3) + flowControl.FalseBlockName = GetStringLiteral(args[2].Expression); + + block.Statements.Add(flowControl); + } + + private void ExtractLoopStatement(InvocationExpressionSyntax invoke, BlockDefinition block, int lineNumber, string sourceCode) + { + var args = invoke.ArgumentList.Arguments; + var flowControl = new FlowControlStatement + { + LineNumber = lineNumber, + SourceCode = sourceCode, + ControlType = FlowControlType.Loop + }; + + if (args.Count >= 1) + flowControl.ConditionExpression = args[0].Expression.ToString(); + if (args.Count >= 2) + flowControl.LoopBlockName = GetStringLiteral(args[1].Expression); + + block.Statements.Add(flowControl); + } + + private string GetMethodName(InvocationExpressionSyntax invoke) + { + if (invoke.Expression is IdentifierNameSyntax identifier) + return identifier.Identifier.Text; + if (invoke.Expression is MemberAccessExpressionSyntax member) + return member.Name.Identifier.Text; + return string.Empty; + } + + private string GetStringLiteral(ExpressionSyntax expr) + { + if (expr is LiteralExpressionSyntax literal && literal.Kind() == SyntaxKind.StringLiteralExpression) + return literal.Token.ValueText; + return expr.ToString().Trim('"'); + } + + private object? GetLiteralValue(LiteralExpressionSyntax literal) + { + return literal.Token.Value; + } + + private int GetLineNumber(SyntaxNode node) + { + var location = node.GetLocation(); + var lineSpan = location.GetLineSpan(); + return lineSpan.StartLinePosition.Line + 1; + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltInFunctions.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltInFunctions.cs new file mode 100644 index 00000000..f4f44a7b --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltInFunctions.cs @@ -0,0 +1,192 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.CSharp.Scripting; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Scripting; +using Microsoft.CodeAnalysis; +using KitX.Core.Contract.Workflow; + +namespace KitX.Core.Workflow.BlockScripting; + +/// +/// Built-in functions for block scripts - these functions are injected into the script execution context +/// +public static class BuiltInFunctions +{ + /// + /// Gets the workflow output for collecting Print() output + /// + private static List _output { get; } = new(); + + /// + /// Condition branch - triggers jump to target block without returning a value + /// + /// + /// This function throws a BranchException which is caught by the execution engine + /// to implement block jumping. The condition determines which branch to take. + /// + /// Boolean condition + /// Block name to jump to when condition is true + /// Block name to jump to when condition is false + public static void Branch(bool condition, string trueBlock, string falseBlock) + { + var targetBlock = condition ? trueBlock : falseBlock; + throw new BranchException(targetBlock); + } + + /// + /// Loop - triggers jump back to a block while condition is true + /// + /// Boolean condition + /// Block name to jump to while condition is true + public static void Loop(bool condition, string loopBlock) + { + if (condition) + throw new BranchException(loopBlock); + // Condition is false, continue to next statement + } + + /// + /// Pause execution for specified milliseconds (for debugging) + /// + /// Milliseconds to sleep + public static void Pause(int milliseconds) + { + Thread.Sleep(milliseconds); + } + + /// + /// Print a value to workflow output + /// + /// Value to print + public static void Print(object? value) + { + WorkflowOutput.WriteLine(value); + _output.Add(value?.ToString() ?? "null"); + } + + /// + /// Clears the accumulated output + /// + public static void ClearOutput() + { + _output.Clear(); + } + + /// + /// Gets all accumulated output + /// + public static IReadOnlyList GetOutput() => _output.AsReadOnly(); +} + +/// +/// Branch exception - used by execution engine to trigger block jumps +/// +public class BranchException : Exception +{ + /// + /// Target block name to jump to + /// + public string TargetBlock { get; } + + /// + /// Creates a new branch exception + /// + /// Target block name + public BranchException(string targetBlock) : base($"Branch to {targetBlock}") + { + TargetBlock = targetBlock; + } +} + +/// +/// Return exception - used by execution engine to return from script +/// +public class ReturnException : Exception +{ + /// + /// Return value + /// + public object? Value { get; } + + /// + /// Creates a new return exception + /// + /// Return value + public ReturnException(object? value) : base("Return from script") + { + Value = value; + } +} + +/// +/// Break exception - used by execution engine to break from loop +/// +public class BreakException : Exception +{ + /// + /// Creates a new break exception + /// + public BreakException() : base("Break from loop") + { + } +} + +/// +/// Script globals for execution - provides access to built-in functions and global state +/// +public class ScriptGlobals +{ + /// + /// Reference to scope manager for variable access + /// + public BlockScopeManager ScopeManager { get; } + + /// + /// Input parameters passed to the script + /// + public Dictionary? Parameters { get; } + + /// + /// Creates script globals + /// + public ScriptGlobals(BlockScopeManager scopeManager, Dictionary? parameters = null) + { + ScopeManager = scopeManager; + Parameters = parameters; + } + + /// + /// Branch to another block + /// + public void Branch(bool condition, string trueBlock, string falseBlock) + { + BuiltInFunctions.Branch(condition, trueBlock, falseBlock); + } + + /// + /// Loop while condition is true + /// + public void Loop(bool condition, string loopBlock) + { + BuiltInFunctions.Loop(condition, loopBlock); + } + + /// + /// Print a value + /// + public void Print(object? value) + { + BuiltInFunctions.Print(value); + } + + /// + /// Pause execution + /// + public void Pause(int milliseconds) + { + BuiltInFunctions.Pause(milliseconds); + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs index b2532690..8587775b 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs @@ -713,6 +713,98 @@ public string MergeHelperFunctions(string mainCode, List helperF return await ExecuteCodesAsync(fullCode, requiredPlugins, includeTimestamp, cancellationToken); } + #region Block Script Methods + + // Block script parser instance + private BlockScripting.BlockScriptParser? _blockScriptParser; + + // Block script executor instance + private BlockScripting.BlockScriptExecutor? _blockScriptExecutor; + + /// + /// Gets the block script parser + /// + private BlockScripting.BlockScriptParser BlockScriptParser => + _blockScriptParser ??= new BlockScripting.BlockScriptParser(); + + /// + /// Gets the block script executor + /// + private BlockScripting.BlockScriptExecutor BlockScriptExecutor => + _blockScriptExecutor ??= new BlockScripting.BlockScriptExecutor(); + + /// + /// 解析块脚本 + /// + public BlockScriptParseResult ParseBlockScript(string sourceCode) + { + return BlockScriptParser.Parse(sourceCode); + } + + /// + /// 异步解析块脚本 + /// + public Task ParseBlockScriptAsync(string sourceCode) + { + return BlockScriptParser.ParseAsync(sourceCode); + } + + /// + /// 验证块脚本 + /// + public BlockScriptValidationResult ValidateBlockScript(string sourceCode) + { + return BlockScriptParser.Validate(sourceCode); + } + + /// + /// 执行块脚本 + /// + public Task ExecuteBlockScriptAsync( + BlockScript script, + Dictionary? parameters = null, + CancellationToken cancellationToken = default) + { + return BlockScriptExecutor.ExecuteAsync(script, parameters, cancellationToken); + } + + /// + /// 从块脚本源代码执行 + /// + public async Task ExecuteBlockScriptAsync( + string sourceCode, + Dictionary? parameters = null, + CancellationToken cancellationToken = default) + { + // 1. Parse the block script + var parseResult = BlockScriptParser.Parse(sourceCode); + + if (!parseResult.IsSuccess || parseResult.Script == null) + { + return new BlockScriptExecutionResult + { + IsSuccess = false, + ErrorMessage = parseResult.ErrorMessage ?? "Failed to parse block script" + }; + } + + // 2. Validate + var validationResult = BlockScriptExecutor.Validate(parseResult.Script); + if (!validationResult.IsValid) + { + return new BlockScriptExecutionResult + { + IsSuccess = false, + ErrorMessage = string.Join("; ", validationResult.Errors) + }; + } + + // 3. Execute + return await BlockScriptExecutor.ExecuteAsync(parseResult.Script, parameters, cancellationToken); + } + + #endregion + #endregion } diff --git a/KitX Standard b/KitX Standard index 13ae5c66..eba7ee42 160000 --- a/KitX Standard +++ b/KitX Standard @@ -1 +1 @@ -Subproject commit 13ae5c66502b25833b18fb474380b7fb6a39a3bb +Subproject commit eba7ee42dae17b00ca35490dbf69de9752dceea0 From 7e96f3fdadedeaceff1008daa58ed5b6d73f1746 Mon Sep 17 00:00:00 2001 From: StarInk Date: Tue, 24 Mar 2026 23:41:31 +0100 Subject: [PATCH 024/110] =?UTF-8?q?=F0=9F=92=BE=20Feat(Workflow):=20?= =?UTF-8?q?=E9=87=8D=E6=9E=84=E3=80=81=E4=BC=98=E5=8C=96=E5=B9=B6=E5=AE=9E?= =?UTF-8?q?=E8=A3=85=E5=9D=97=E8=84=9A=E6=9C=AC=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Device/DevicesDiscoveryServer.cs | 2 +- .../BlockScripting/BlockScopeManager.cs | 23 - .../BlockScriptExecutionGlobals.cs | 129 ++ .../BlockScripting/BlockScriptExecutor.cs | 626 ++++++--- .../BlockScripting/BlockScriptParser.cs | 1183 +++++++++++------ .../BlockScripting/BuiltInFunctions.cs | 192 --- .../KitX.Core/Workflow/KcsFileService.cs | 14 +- .../Workflow/WorkflowScriptService.cs | 141 +- KitX Clients/KitX Dashboard | 2 +- KitX Standard | 2 +- 10 files changed, 1410 insertions(+), 904 deletions(-) create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutionGlobals.cs delete mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltInFunctions.cs diff --git a/KitX Clients/KitX Core/KitX.Core/Device/DevicesDiscoveryServer.cs b/KitX Clients/KitX Core/KitX.Core/Device/DevicesDiscoveryServer.cs index 51a78f24..671427f6 100644 --- a/KitX Clients/KitX Core/KitX.Core/Device/DevicesDiscoveryServer.cs +++ b/KitX Clients/KitX Core/KitX.Core/Device/DevicesDiscoveryServer.cs @@ -362,7 +362,7 @@ private void MultiDevicesBroadCastReceive() var result = System.Text.Encoding.UTF8.GetString(bytes); - Log.Information($"UDP From: {client, -21}, Receive: {result}"); + Log.Verbose($"UDP From: {client, -21}, Receive: {result}"); try { diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScopeManager.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScopeManager.cs index ac07b79a..cccf4c47 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScopeManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScopeManager.cs @@ -26,21 +26,6 @@ public BlockScopeManager() /// public IBlockScope GlobalScope => _globalScope; - /// - /// Creates a new local scope for a block - /// - public IBlockScope CreateLocalScope(string blockName) - { - if (_localScopes.TryGetValue(blockName, out var existing)) - { - return existing; - } - - var scope = new BlockScope(blockName, isGlobal: false); - _localScopes[blockName] = scope; - return scope; - } - /// /// Resolves a variable name to its value (searches local then global) /// @@ -126,14 +111,6 @@ public void InitializeGlobalScope(BlockScript script) } } } - - /// - /// Gets a local scope by block name - /// - public IBlockScope? GetLocalScope(string blockName) - { - return _localScopes.TryGetValue(blockName, out var scope) ? scope : null; - } } /// diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutionGlobals.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutionGlobals.cs new file mode 100644 index 00000000..d53522d6 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutionGlobals.cs @@ -0,0 +1,129 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using KitX.Core.Workflow; + +namespace KitX.Core.Workflow.BlockScripting; + +/// +/// Script globals for block script execution - provides access to built-in functions and collects output +/// +public class BlockScriptExecutionGlobals +{ + private readonly BlockScopeManager _scopeManager; + private readonly List _output; + private readonly Dictionary _variables = new(); + + /// + /// NextBlock 内置变量 - 设置后执行器会跳转到指定块 + /// 每个块执行前会被重置为 null + /// + public string? NextBlock { get; set; } = null; + + /// + /// Creates script globals + /// + public BlockScriptExecutionGlobals(BlockScopeManager scopeManager, List output) + { + _scopeManager = scopeManager; + _output = output; + } + + /// + /// Gets a variable value - called by CSharpScript when accessing unknown properties + /// + public object? GetVariable(string name) + { + if (name == "NextBlock") + return NextBlock; + if (_variables.TryGetValue(name, out var value)) + return value; + return _scopeManager.ResolveVariable(name); + } + + /// + /// Sets a variable value + /// + public void SetVariable(string name, object? value) + { + if (name == "NextBlock") + { + NextBlock = value as string; + return; + } + _variables[name] = value; + _scopeManager.SetVariable(name, value, global: false); + } + + /// + /// Sets a variable value in global scope + /// + public void SetGlobalVariable(string name, object? value) + { + if (name == "NextBlock") + { + NextBlock = value as string; + return; + } + _variables[name] = value; + _scopeManager.SetVariable(name, value, global: true); + } + + /// + /// Resets NextBlock to null (called before each block execution) + /// + public void ResetNextBlock() + { + NextBlock = null; + } + + /// + /// Gets all variables for debugging + /// + public Dictionary GetAllVariables() => new(_variables); + + /// + /// Condition branch - sets NextBlock and returns the target block name + /// + public string? Branch(bool condition, string trueBlock, string falseBlock) + { + NextBlock = condition ? trueBlock : falseBlock; + return NextBlock; + } + + /// + /// Loop while condition is true (three-argument syntax) + /// + public string? Loop(bool condition, string trueBlock, string falseBlock) + { + NextBlock = condition ? trueBlock : falseBlock; + return NextBlock; + } + + /// + /// Print a value + /// + public void Print(object? value) + { + var str = value?.ToString() ?? "null"; + _output.Add(str); + WorkflowOutput.WriteLine(value); + } + + /// + /// Pause execution + /// + public void Pause(int milliseconds) + { + Thread.Sleep(milliseconds); + } + + /// + /// LoopBodyEnd - marks the end of a loop body and returns to the loop condition block + /// + public string? LoopBodyEnd(string parentBlockName) + { + NextBlock = $"{parentBlockName}_Loop"; + return NextBlock; + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutor.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutor.cs index f13945a2..170694c9 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutor.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutor.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; @@ -20,6 +20,10 @@ public class BlockScriptExecutor : IBlockScriptExecutor { private readonly BlockScopeManager _scopeManager; private readonly Stopwatch _stopwatch = new(); + private ScriptState? _scriptState; + private BlockScript? _currentScript; + private List _output = new(); + private BlockScriptExecutionGlobals? _globals; /// /// Creates a new block script executor @@ -46,14 +50,18 @@ public async Task ExecuteAsync( CancellationToken cancellationToken = default) { _stopwatch.Restart(); - var output = new List(); + _output = new List(); var executedBlockCount = 0; try { + // Store script reference for use in EvaluateExpressionAsync + _currentScript = script; + // Clear previous execution state _scopeManager.ClearLocalScopes(); - BuiltInFunctions.ClearOutput(); + _output.Clear(); // Clear the executor's output list + _scriptState = null; // Initialize global scope with ConstBlock and PubVarBlock _scopeManager.InitializeGlobalScope(script); @@ -67,23 +75,37 @@ public async Task ExecuteAsync( } } - // Build script globals with all required members - var globals = new BlockScriptExecutionGlobals(_scopeManager, output); + // Initialize CSharpScript session with all variable declarations + await InitializeScriptSessionAsync(cancellationToken); + + // Execute ConstBlock and PubVarBlock (global variables) FIRST for initialization only + // They are executed ONCE to establish variables in scope, then we proceed to MainBlock + // NOTE: We do NOT follow their NextBlockName chains - those are only for sequential linking + Log.Debug("[BlockScriptExecutor] === Starting Block Execution ==="); + Log.Debug("[BlockScriptExecutor] script.ConstBlock is {IsNull}, script.PubVarBlock is {IsNull2}, script.MainBlock is {IsNull3}", + script.ConstBlock == null ? "null" : "NOT null", + script.PubVarBlock == null ? "null" : "NOT null", + script.MainBlock == null ? "null" : "NOT null"); - // 1. Execute ConstBlock and PubVarBlock (global variables) + // Execute ConstBlock if exists (just for initialization) if (script.ConstBlock != null) { + Log.Debug("[BlockScriptExecutor] >>> About to execute ConstBlock (initialization only)"); executedBlockCount++; - await ExecuteBlockAsync(script.ConstBlock, globals, cancellationToken); + await ExecuteBlockAsync(script.ConstBlock, cancellationToken); + Log.Debug("[BlockScriptExecutor] <<< ConstBlock initialization complete"); } + // Execute PubVarBlock if exists (just for initialization) if (script.PubVarBlock != null) { + Log.Debug("[BlockScriptExecutor] >>> About to execute PubVarBlock (initialization only)"); executedBlockCount++; - await ExecuteBlockAsync(script.PubVarBlock, globals, cancellationToken); + await ExecuteBlockAsync(script.PubVarBlock, cancellationToken); + Log.Debug("[BlockScriptExecutor] <<< PubVarBlock initialization complete"); } - // 2. Execute MainBlock + // MainBlock is the entry point for execution if (script.MainBlock == null) { return new BlockScriptExecutionResult @@ -92,45 +114,55 @@ public async Task ExecuteAsync( ErrorMessage = "No MainBlock found in script", ExecutedBlockCount = executedBlockCount, ExecutionTimeMs = _stopwatch.ElapsedMilliseconds, - Output = output + Output = _output }; } + Log.Debug("[BlockScriptExecutor] >>> About to execute MainBlock as entry point"); executedBlockCount++; - await ExecuteBlockAsync(script.MainBlock, globals, cancellationToken); - - _stopwatch.Stop(); + var mainResult = await ExecuteBlockAsync(script.MainBlock, cancellationToken); + Log.Debug("[BlockScriptExecutor] <<< MainBlock executed, mainResult.NextBlockName = {NextBlock}, ShouldContinue = {ShouldContinue}", + mainResult.NextBlockName ?? "(null)", mainResult.ShouldContinue); - return new BlockScriptExecutionResult - { - IsSuccess = true, - ExecutedBlockCount = executedBlockCount, - ExecutionTimeMs = _stopwatch.ElapsedMilliseconds, - Output = output - }; - } - catch (ReturnException returnEx) - { - _stopwatch.Stop(); - return new BlockScriptExecutionResult + // Follow the execution chain using while loop + while (mainResult.ShouldContinue && !string.IsNullOrEmpty(mainResult.NextBlockName)) { - IsSuccess = true, - ReturnValue = returnEx.Value, - ExecutedBlockCount = executedBlockCount, - ExecutionTimeMs = _stopwatch.ElapsedMilliseconds, - Output = output - }; - } - catch (BranchException branchEx) - { + var nextBlockName = mainResult.NextBlockName; + + Log.Debug("[BlockScriptExecutor] Chain continuation: nextBlockName = {NextBlock}", + nextBlockName ?? "(null)"); + + if (string.IsNullOrEmpty(nextBlockName)) + { + Log.Debug("[BlockScriptExecutor] No more blocks in chain, ending execution"); + break; + } + + var nextBlock = script.GetBlockByName(nextBlockName); + Log.Debug("[BlockScriptExecutor] Fetched nextBlock: {BlockName} (type={BlockType})", + nextBlock?.Name ?? "null", nextBlock?.Type); + if (nextBlock == null) + { + Log.Warning("[BlockScriptExecutor] Block '{BlockName}' not found, ending chain", nextBlockName); + break; + } + + var (chainResult, newBlockCount, _) = await ExecuteBlockChainAsync(nextBlock, cancellationToken, executedBlockCount); + mainResult = chainResult; + executedBlockCount = newBlockCount; + + Log.Debug("[BlockScriptExecutor] Chain iteration complete: mainResult.NextBlockName = {NextBlock}, ShouldContinue = {ShouldContinue}", + mainResult.NextBlockName ?? "(null)", mainResult.ShouldContinue); + } + _stopwatch.Stop(); return new BlockScriptExecutionResult { - IsSuccess = false, - ErrorMessage = $"Unresolved branch to '{branchEx.TargetBlock}' at end of block execution", + IsSuccess = mainResult.ShouldContinue || mainResult.IsReturn, + ReturnValue = mainResult.ReturnValue, ExecutedBlockCount = executedBlockCount, ExecutionTimeMs = _stopwatch.ElapsedMilliseconds, - Output = output + Output = _output }; } catch (Exception ex) @@ -143,11 +175,84 @@ public async Task ExecuteAsync( ErrorMessage = $"Execution error: {ex.Message}", ExecutedBlockCount = executedBlockCount, ExecutionTimeMs = _stopwatch.ElapsedMilliseconds, - Output = output + Output = _output }; } } + /// + /// Executes a chain of blocks following NextBlockName + /// Note: This only follows NextBlockName when a block ends NATURALLY (no flow control). + /// When flow control returns a NextBlockName (Loop/Branch), caller should handle the跳转. + /// + /// Block to start execution from + /// Cancellation token + /// Initial block count for tracking + /// Tuple of (Result, ExecutedBlockCount, ChainEndedNaturally) where ChainEndedNaturally + /// is true if chain ended naturally (no flow control jump), false if returned early due to + /// flow control setting NextBlockName + private async Task<(BlockExecutionResult Result, int ExecutedBlockCount, bool ChainEndedNaturally)> ExecuteBlockChainAsync( + BlockDefinition startBlock, + CancellationToken cancellationToken, + int initialBlockCount) + { + var chainEndedNaturally = false; + var currentBlock = startBlock; + var executedBlockCount = initialBlockCount; + + while (currentBlock != null) + { + cancellationToken.ThrowIfCancellationRequested(); + + executedBlockCount++; + Log.Debug("[BlockScriptExecutor] Executing block '{BlockName}' (type={BlockType})", + currentBlock.Name, currentBlock.Type); + + // Execute block and get result + var result = await ExecuteBlockAsync(currentBlock, cancellationToken); + + // Check if execution should continue + if (!result.ShouldContinue) + { + // Return/break - propagate result up to caller + return (result, executedBlockCount, chainEndedNaturally); + } + + // If NextBlockName is set from flow control (Loop/Branch), return to caller + // DO NOT auto-jump - caller will handle the跳转 + if (!string.IsNullOrEmpty(result.NextBlockName)) + { + Log.Debug("[BlockScriptExecutor] Block '{BlockName}' has flow control NextBlockName = '{NextBlock}', returning to caller", + currentBlock.Name, result.NextBlockName); + // chainEndedNaturally is already false + return (result, executedBlockCount, false); + } + + // Block ended naturally - follow NextBlockName if exists + if (string.IsNullOrEmpty(currentBlock.NextBlockName)) + { + // End of execution chain + Log.Debug("[BlockScriptExecutor] Block '{BlockName}' has no NextBlockName, ending chain", + currentBlock.Name); + chainEndedNaturally = true; + break; + } + + currentBlock = _currentScript?.GetBlockByName(currentBlock.NextBlockName); + if (currentBlock == null) + { + Log.Warning("[BlockScriptExecutor] Block '{BlockName}' references non-existent NextBlock '{NextBlock}'", + currentBlock?.Name, currentBlock?.NextBlockName); + chainEndedNaturally = true; + break; + } + } + + // Chain ended naturally + chainEndedNaturally = true; + return (BlockExecutionResult.ContinueTo(null), executedBlockCount, true); + } + /// /// Executes a specific block by name /// @@ -166,8 +271,7 @@ public async Task ExecuteBlockAsync( }; } - var globals = new BlockScriptExecutionGlobals(_scopeManager, new List()); - await ExecuteBlockAsync(block, globals, cancellationToken); + await ExecuteBlockAsync(block, cancellationToken); return new BlockScriptExecutionResult { @@ -193,6 +297,16 @@ public BlockScriptValidationResult Validate(BlockScript script) var allBlockNames = script.NamedBlocks.Keys.ToHashSet(); if (script.MainBlock != null) allBlockNames.Add(script.MainBlock.Name); + if (script.ConstBlock != null) + allBlockNames.Add(script.ConstBlock.Name); + if (script.PubVarBlock != null) + allBlockNames.Add(script.PubVarBlock.Name); + + // Add LoopBlocks to the set of known blocks + foreach (var loopBlock in script.LoopBlocks.Values) + { + allBlockNames.Add(loopBlock.Name); + } foreach (var block in script.AllBlocks) { @@ -215,10 +329,25 @@ public BlockScriptValidationResult Validate(BlockScript script) } else if (flow.ControlType == FlowControlType.Loop) { - if (!string.IsNullOrEmpty(flow.LoopBlockName) && - !allBlockNames.Contains(flow.LoopBlockName)) + // Loop(cond, trueBlock, falseBlock) syntax + if (!string.IsNullOrEmpty(flow.TrueBlockName) && + !allBlockNames.Contains(flow.TrueBlockName)) { - result.AddError($"Block '{flow.LoopBlockName}' referenced in Loop at line {flow.LineNumber} does not exist"); + result.AddError($"Block '{flow.TrueBlockName}' referenced in Loop at line {flow.LineNumber} does not exist"); + } + if (!string.IsNullOrEmpty(flow.FalseBlockName) && + !allBlockNames.Contains(flow.FalseBlockName)) + { + result.AddError($"Block '{flow.FalseBlockName}' referenced in Loop at line {flow.LineNumber} does not exist"); + } + } + else if (flow.ControlType == FlowControlType.LoopBodyEnd) + { + // LoopBodyEnd should reference a block that contains a Loop + if (!string.IsNullOrEmpty(flow.LoopBodyEndReturnTo) && + !allBlockNames.Contains(flow.LoopBodyEndReturnTo)) + { + result.AddError($"Block '{flow.LoopBodyEndReturnTo}' referenced in LoopBodyEnd at line {flow.LineNumber} does not exist"); } } } @@ -232,21 +361,15 @@ public BlockScriptValidationResult Validate(BlockScript script) } /// - /// Executes a single block + /// Executes a single block and returns execution result for state machine + /// New design: NextBlock is set by Loop/Branch expressions, checked after each statement /// - private async Task ExecuteBlockAsync( + private async Task ExecuteBlockAsync( BlockDefinition block, - BlockScriptExecutionGlobals globals, CancellationToken cancellationToken) { - // Create local scope for this block - var localScope = _scopeManager.CreateLocalScope(block.Name); - - // Declare local variables in scope - foreach (var varDecl in block.Variables) - { - localScope.SetVariable(varDecl.Name, varDecl.DefaultValue); - } + // Reset NextBlock at the start of each block + _globals?.ResetNextBlock(); // Execute statements in order for (int i = 0; i < block.Statements.Count; i++) @@ -254,47 +377,86 @@ private async Task ExecuteBlockAsync( cancellationToken.ThrowIfCancellationRequested(); var statement = block.Statements[i]; - await ExecuteStatementAsync(statement, globals, cancellationToken); + await ExecuteStatementAsync(block, statement, cancellationToken); + + // Check if NextBlock was set by the statement (Loop/Branch/assignment) + if (!string.IsNullOrEmpty(_globals?.NextBlock)) + { + // NextBlock was set - use it for跳转 + var nextBlock = _globals.NextBlock; + _globals.ResetNextBlock(); // Reset for next block + return BlockExecutionResult.ContinueTo(nextBlock); + } + // Otherwise, continue to next statement } + + // Block ended naturally - continue to next block if exists (natural flow) + return BlockExecutionResult.ContinueTo(block.NextBlockName); } /// - /// Executes a single statement + /// Executes a single statement and returns execution result for state machine /// - private async Task ExecuteStatementAsync( + private async Task ExecuteStatementAsync( + BlockDefinition currentBlock, BlockStatement statement, - BlockScriptExecutionGlobals globals, CancellationToken cancellationToken) { try { if (statement is ExpressionStatement exprStmt) { - await ExecuteExpressionAsync(exprStmt.Expression, globals, cancellationToken); + await ExecuteExpressionAsync(exprStmt.Expression, cancellationToken); + return BlockExecutionResult.ContinueTo(null); // Continue to next statement } else if (statement is VariableDeclarationStatement varStmt) { - // Variable declaration is handled in ExecuteBlockAsync - // For now, we re-evaluate to set the value - if (!string.IsNullOrEmpty(varStmt.Declaration.InitialValueExpression)) + // Check if DefaultValue is pre-computed (for ConstBlock variables) + // If so, use it directly instead of evaluating through CSharpScript + if (varStmt.Declaration.DefaultValue != null) { - await EvaluateExpressionAsync(varStmt.Declaration.InitialValueExpression, globals, cancellationToken); + _scopeManager.SetVariable(varStmt.Declaration.Name, varStmt.Declaration.DefaultValue); + Serilog.Log.Debug("[BlockScriptExecutor] Declared variable {Name} = {Value} (from DefaultValue)", + varStmt.Declaration.Name, varStmt.Declaration.DefaultValue); } + else + { + // For PubVarBlock or variables without pre-computed values, + // evaluate the initial value expression + var initialValue = varStmt.Declaration.InitialValueExpression; + if (!string.IsNullOrEmpty(initialValue)) + { + try + { + // Evaluate just the initial value expression + var valueResult = await EvaluateExpressionAsync(initialValue, cancellationToken); + // Store the variable via SetVariable + _scopeManager.SetVariable(varStmt.Declaration.Name, valueResult); + Serilog.Log.Debug("[BlockScriptExecutor] Declared variable {Name} = {Value}", + varStmt.Declaration.Name, valueResult); + } + catch (Exception ex) + { + Log.Warning(ex, "[BlockScriptExecutor] Error evaluating initial value for {Name}", + varStmt.Declaration.Name); + // Still declare the variable with null/default + _scopeManager.SetVariable(varStmt.Declaration.Name, null); + } + } + else + { + // Declare without initial value + _scopeManager.SetVariable(varStmt.Declaration.Name, null); + } + } + return BlockExecutionResult.ContinueTo(null); // Continue to next statement } else if (statement is FlowControlStatement flowStmt) { - await ExecuteFlowControlAsync(flowStmt, globals, cancellationToken); + // ExecuteFlowControlAsync returns BlockExecutionResult for state machine + return await ExecuteFlowControlAsync(currentBlock, flowStmt, cancellationToken); } - } - catch (BranchException ex) - { - // Rethrow to be handled by execution engine - throw; - } - catch (ReturnException ex) - { - // Rethrow to be handled by execution engine - throw; + return BlockExecutionResult.ContinueTo(null); // Continue to next statement } catch (Exception ex) { @@ -308,23 +470,13 @@ private async Task ExecuteStatementAsync( /// private async Task ExecuteExpressionAsync( string expression, - BlockScriptExecutionGlobals globals, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(expression)) return; - // Check if it's an assignment - if (expression.Contains('=') && !expression.Contains("==") && !expression.Contains("!=")) - { - // This is an assignment - execute as script - await EvaluateExpressionAsync(expression, globals, cancellationToken); - } - else - { - // This is a function call or other expression - await EvaluateExpressionAsync(expression, globals, cancellationToken); - } + // Execute the expression (assignment or function call) + await EvaluateExpressionAsync(expression, cancellationToken); } /// @@ -332,7 +484,6 @@ private async Task ExecuteExpressionAsync( /// private async Task EvaluateExpressionAsync( string expression, - BlockScriptExecutionGlobals globals, CancellationToken cancellationToken) { try @@ -340,16 +491,54 @@ private async Task ExecuteExpressionAsync( // Build a complete statement for execution var code = WrapExpressionAsStatement(expression); - var result = await CSharpScript.EvaluateAsync( - code, - ScriptOptions.Default - .WithReferences(typeof(BuiltInFunctions).Assembly) - .WithImports( - "System", - "KitX.Core.Workflow.BlockScripting" - ), - globals: globals, - cancellationToken: cancellationToken); + // Determine if we need to prepend helpers: + // - If _scriptState is null (first evaluation): prepend helpers, they need to be defined + // - If _scriptState != null (subsequent evaluations via ContinueWithAsync): + // DON'T prepend helpers, they were already defined in the initialization script + // and ContinueWithAsync shares the same script state + var fullCode = _scriptState == null + ? BuildCodeWithHelpers(code) + : code; + + // Log the full code being evaluated (truncated if too long) + var codePreview = fullCode.Length > 500 ? fullCode.Substring(0, 500) + "..." : fullCode; + Log.Debug("[BlockScriptExecutor] Evaluating expression. Code:\n{Code}", codePreview); + + object? result; + if (_scriptState != null) + { + // Use ContinueWithAsync to evaluate in the context of the compiled script + // Variables and helpers declared in previous evaluations are preserved + _scriptState = await _scriptState.ContinueWithAsync( + fullCode, + ScriptOptions.Default + .WithReferences(typeof(BlockScriptExecutionGlobals).Assembly) + .WithImports( + "System", + "KitX.Core.Workflow.BlockScripting", + "KitX.Core.Workflow" + ), + cancellationToken: cancellationToken); + result = _scriptState.ReturnValue; + } + else + { + // First evaluation: create initial script state using RunAsync to get ScriptState + _globals = new BlockScriptExecutionGlobals(_scopeManager, _output); + var globals = _globals; + _scriptState = await CSharpScript.RunAsync( + fullCode, + ScriptOptions.Default + .WithReferences(typeof(BlockScriptExecutionGlobals).Assembly) + .WithImports( + "System", + "KitX.Core.Workflow.BlockScripting", + "KitX.Core.Workflow" + ), + globals: globals, + cancellationToken: cancellationToken); + result = _scriptState.ReturnValue; + } return result; } @@ -372,124 +561,193 @@ private string WrapExpressionAsStatement(string expression) } /// - /// Executes flow control statement + /// Builds helper function code from a list of helper functions /// - private async Task ExecuteFlowControlAsync( - FlowControlStatement statement, - BlockScriptExecutionGlobals globals, - CancellationToken cancellationToken) + private string BuildHelperFunctionsCode(List? helperFunctions) { - switch (statement.ControlType) + if (helperFunctions == null || helperFunctions.Count == 0) + return string.Empty; + + var code = new System.Text.StringBuilder(); + + foreach (var func in helperFunctions) { - case FlowControlType.Branch: - // Evaluate condition and throw BranchException - if (!string.IsNullOrEmpty(statement.ConditionExpression)) - { - var condition = await EvaluateExpressionAsync(statement.ConditionExpression, globals, cancellationToken); - if (condition is bool boolCondition) - { - var targetBlock = boolCondition ? statement.TrueBlockName : statement.FalseBlockName; - if (!string.IsNullOrEmpty(targetBlock)) - { - throw new BranchException(targetBlock); - } - } - } - break; + // Generate function signature (without static modifier for CSharpScript) + code.Append(func.ReturnType); + code.Append(" "); + code.Append(func.Name); + code.Append("("); + + // Add parameters + for (int i = 0; i < func.Parameters.Count; i++) + { + if (i > 0) code.Append(", "); + code.Append(func.Parameters[i].Type); + code.Append(" "); + code.Append(func.Parameters[i].Name); + } - case FlowControlType.Loop: - // Check condition and throw BranchException if true - if (!string.IsNullOrEmpty(statement.ConditionExpression)) - { - var condition = await EvaluateExpressionAsync(statement.ConditionExpression, globals, cancellationToken); - if (condition is bool boolCondition && boolCondition) - { - if (!string.IsNullOrEmpty(statement.LoopBlockName)) - { - throw new BranchException(statement.LoopBlockName); - } - } - } - break; + code.AppendLine(")"); + code.AppendLine("{"); - case FlowControlType.Return: - if (!string.IsNullOrEmpty(statement.ConditionExpression)) + // Add function body + if (!string.IsNullOrWhiteSpace(func.Code)) + { + foreach (var line in func.Code.Split('\n')) { - var returnValue = await EvaluateExpressionAsync(statement.ConditionExpression, globals, cancellationToken); - throw new ReturnException(returnValue); + code.AppendLine(" " + line); } - throw new ReturnException(null); + } - case FlowControlType.Break: - throw new BreakException(); + code.AppendLine("}"); + code.AppendLine(); } - } -} - -/// -/// Script globals for block script execution - provides access to built-in functions and collects output -/// -public class BlockScriptExecutionGlobals -{ - private readonly BlockScopeManager _scopeManager; - private readonly List _output; - /// - /// Creates script globals - /// - public BlockScriptExecutionGlobals(BlockScopeManager scopeManager, List output) - { - _scopeManager = scopeManager; - _output = output; + return code.ToString(); } /// - /// Gets a variable value + /// Builds complete code by prepending helper function definitions /// - public object? GetVariable(string name) + private string BuildCodeWithHelpers(string code) { - return _scopeManager.ResolveVariable(name); - } + var helperCode = BuildHelperFunctionsCode(_currentScript?.HelperFunctions); + if (string.IsNullOrEmpty(helperCode)) + return code; - /// - /// Sets a variable value - /// - public void SetVariable(string name, object? value) - { - _scopeManager.SetVariable(name, value, global: false); + return helperCode + code; } /// - /// Condition branch + /// Builds a complete initialization script that declares all variables + /// This should be run once to properly initialize the CSharpScript session /// - public void Branch(bool condition, string trueBlock, string falseBlock) + private string BuildInitializationScript(BlockScript script) { - BuiltInFunctions.Branch(condition, trueBlock, falseBlock); - } + var initCode = new System.Text.StringBuilder(); - /// - /// Loop while condition is true - /// - public void Loop(bool condition, string loopBlock) - { - BuiltInFunctions.Loop(condition, loopBlock); + // Add helper functions first + initCode.Append(BuildHelperFunctionsCode(script.HelperFunctions)); + + // Add variable declarations from ConstBlock and PubVarBlock + if (script.ConstBlock != null) + { + foreach (var variable in script.ConstBlock.Variables) + { + if (variable.DefaultValue != null) + { + initCode.AppendLine($"var {variable.Name} = {variable.DefaultValue};"); + } + } + } + + if (script.PubVarBlock != null) + { + foreach (var variable in script.PubVarBlock.Variables) + { + if (variable.DefaultValue != null) + { + initCode.AppendLine($"var {variable.Name} = {variable.DefaultValue};"); + } + } + } + + return initCode.ToString(); } /// - /// Print a value + /// Initializes the CSharpScript session with all variable declarations /// - public void Print(object? value) + private async Task InitializeScriptSessionAsync(CancellationToken cancellationToken) { - var str = value?.ToString() ?? "null"; - _output.Add(str); - WorkflowOutput.WriteLine(value); + if (_currentScript == null) return; + + var initCode = BuildInitializationScript(_currentScript); + if (string.IsNullOrWhiteSpace(initCode)) + { + // No initialization needed, just do a simple first evaluation to establish session + _globals = new BlockScriptExecutionGlobals(_scopeManager, _output); + _scriptState = await CSharpScript.RunAsync( + "0", // Simple expression to establish session + ScriptOptions.Default + .WithReferences(typeof(BlockScriptExecutionGlobals).Assembly) + .WithImports("System", "KitX.Core.Workflow.BlockScripting", "KitX.Core.Workflow"), + globals: _globals, + cancellationToken: cancellationToken); + return; + } + + Log.Debug("[BlockScriptExecutor] Running initialization script:\n{InitCode}", initCode); + + try + { + _globals = new BlockScriptExecutionGlobals(_scopeManager, _output); + _scriptState = await CSharpScript.RunAsync( + initCode, + ScriptOptions.Default + .WithReferences(typeof(BlockScriptExecutionGlobals).Assembly) + .WithImports("System", "KitX.Core.Workflow.BlockScripting", "KitX.Core.Workflow"), + globals: _globals, + cancellationToken: cancellationToken); + } + catch (Exception ex) + { + Log.Warning(ex, "[BlockScriptExecutor] Initialization script failed, will try without it"); + // Fall back to simple session establishment + _globals = new BlockScriptExecutionGlobals(_scopeManager, _output); + _scriptState = await CSharpScript.RunAsync( + "0", + ScriptOptions.Default + .WithReferences(typeof(BlockScriptExecutionGlobals).Assembly) + .WithImports("System", "KitX.Core.Workflow.BlockScripting", "KitX.Core.Workflow"), + globals: _globals, + cancellationToken: cancellationToken); + } } /// - /// Pause execution + /// Executes flow control statement and returns result for state machine /// - public void Pause(int milliseconds) + private async Task ExecuteFlowControlAsync( + BlockDefinition currentBlock, + FlowControlStatement statement, + CancellationToken cancellationToken) { - BuiltInFunctions.Pause(milliseconds); + switch (statement.ControlType) + { + case FlowControlType.Return: + // Return statement: evaluate the return value expression + if (!string.IsNullOrEmpty(statement.ConditionExpression)) + { + var returnValue = await EvaluateExpressionAsync(statement.ConditionExpression, cancellationToken); + return BlockExecutionResult.Return(returnValue); + } + return BlockExecutionResult.Return(null); + + case FlowControlType.Break: + // Break exits the current loop - for now, just end the script + return BlockExecutionResult.Return(null); + + default: + // Branch, Loop, LoopBodyEnd, etc. + // Execute the full statement (e.g., "NextBlock = Loop(...)" or "NextBlock = Branch(...)") + // The built-in function will set _globals.NextBlock + await EvaluateExpressionAsync(statement.SourceCode, cancellationToken); + + // Check if NextBlock was set by the built-in function + var nextBlock = _globals?.NextBlock; + if (!string.IsNullOrEmpty(nextBlock)) + { + return BlockExecutionResult.ContinueTo(nextBlock); + } + + // Fallback: if NextBlock wasn't set, use the stored target based on control type + return statement.ControlType switch + { + FlowControlType.Loop => BlockExecutionResult.ContinueTo(statement.TrueBlockName), + FlowControlType.Branch => BlockExecutionResult.ContinueTo(statement.TrueBlockName), + _ => BlockExecutionResult.ContinueTo(null) + }; + } } } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptParser.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptParser.cs index 24049d68..43c7ba74 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptParser.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptParser.cs @@ -1,9 +1,10 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; @@ -11,13 +12,200 @@ namespace KitX.Core.Workflow.BlockScripting; +/// +/// Recognizes block structure using source text scanning and Roslyn for C# code validation +/// DSL Syntax: +/// #ConstBlock +/// #PubVarBlock +/// #MainBlock +/// #Block BlockName +/// Next block start = previous block end (no explicit end marker needed) +/// Block markers must be on their own line (excluding whitespace) +/// +internal class BlockStructureRecognizer +{ + private readonly string _sourceCode; + private readonly SourceText _sourceText; + private readonly List _blocks = new(); + + public bool HasErrors { get; private set; } + public string ErrorMessage { get; private set; } = string.Empty; + public int ErrorLine { get; private set; } + + public IReadOnlyList Blocks => _blocks; + + public BlockStructureRecognizer(string sourceCode) + { + _sourceCode = sourceCode; + _sourceText = SourceText.From(sourceCode); + } + + /// + /// Recognizes block structure from source code + /// + public void RecognizeBlocks() + { + if (string.IsNullOrWhiteSpace(_sourceCode)) + { + HasErrors = true; + ErrorMessage = "Source code is empty"; + ErrorLine = 0; + return; + } + + var lines = _sourceCode.Split('\n'); + RecognizedBlock? currentBlock = null; + int currentBlockContentStart = -1; + + for (int i = 0; i < lines.Length; i++) + { + var line = lines[i]; + var trimmed = line.Trim(); + + // Check if this line is a block marker + var blockType = TryParseBlockMarker(trimmed); + if (blockType.HasValue) + { + // Finish previous block + if (currentBlock != null) + { + currentBlock.ContentEnd = GetLineStartPosition(i); + currentBlock.Content = ExtractPureCode(currentBlockContentStart, currentBlock.ContentEnd); + _blocks.Add(currentBlock); + } + + // Extract block name for NamedBlock + var blockName = string.Empty; + if (blockType == BlockType.NamedBlock) + { + blockName = trimmed.Substring("#Block ".Length).Trim(); + } + + // Start new block + currentBlock = new RecognizedBlock + { + BlockType = blockType.Value, + BlockName = blockName, + StartLine = i + 1, + ContentStart = GetLineStartPosition(i + 1), // Next line + ContentEnd = -1 + }; + currentBlockContentStart = currentBlock.ContentStart; + } + } + + // Handle last block + if (currentBlock != null) + { + currentBlock.ContentEnd = _sourceCode.Length; + currentBlock.Content = ExtractPureCode(currentBlockContentStart, currentBlock.ContentEnd); + _blocks.Add(currentBlock); + } + + // Validate: must have MainBlock + if (!_blocks.Any(b => b.BlockType == BlockType.MainBlock)) + { + HasErrors = true; + ErrorMessage = "Script must have a #MainBlock"; + ErrorLine = 0; + } + } + + /// + /// Gets the character position in source code for the start of a line (0-indexed) + /// + private int GetLineStartPosition(int zeroBasedLineIndex) + { + if (zeroBasedLineIndex <= 0) return 0; + if (zeroBasedLineIndex >= _sourceText.Lines.Count) return _sourceCode.Length; + + return _sourceText.Lines[zeroBasedLineIndex].Start; + } + + /// + /// Attempts to parse a block marker line + /// + private BlockType? TryParseBlockMarker(string trimmed) + { + // #ConstBlock + if (trimmed == "#ConstBlock") + return BlockType.ConstBlock; + + // #PubVarBlock + if (trimmed == "#PubVarBlock") + return BlockType.PubVarBlock; + + // #MainBlock + if (trimmed == "#MainBlock") + return BlockType.MainBlock; + + // #Block Name + if (trimmed.StartsWith("#Block ")) + { + var name = trimmed.Substring("#Block ".Length).Trim(); + if (!string.IsNullOrEmpty(name)) + return BlockType.NamedBlock; + } + + return null; + } + + /// + /// Extracts pure C# code from content range, removing block marker lines + /// + private string ExtractPureCode(int start, int end) + { + if (start >= end || start < 0 || end > _sourceCode.Length) + return string.Empty; + + var code = _sourceCode.Substring(start, end - start); + var lines = code.Split('\n'); + var pureLines = new List(); + + foreach (var line in lines) + { + var trimmed = line.Trim(); + + // Skip block marker lines + if (trimmed == "#ConstBlock" || + trimmed == "#PubVarBlock" || + trimmed == "#MainBlock" || + trimmed.StartsWith("#Block ")) + { + continue; + } + + pureLines.Add(line); + } + + var result = string.Join("\n", pureLines).Trim(); + Log.Debug("[BlockStructureRecognizer] Extracted pure code ({Length} chars): {Preview}", + result.Length, result.Length > 100 ? result.Substring(0, 100) + "..." : result); + + return result; + } +} + +/// +/// Represents a recognized block with its type, name, and source position +/// +internal class RecognizedBlock +{ + public BlockType BlockType { get; set; } + public string BlockName { get; set; } = string.Empty; + public int StartLine { get; set; } + public int ContentStart { get; set; } + public int ContentEnd { get; set; } + public string Content { get; set; } = string.Empty; +} + /// /// Block script parser implementation using Roslyn CSharp syntax analysis /// public class BlockScriptParser : IBlockScriptParser { /// - /// Parses a block-based script from source code + /// Parses a block-based script from source code using new DSL syntax /// public BlockScriptParseResult Parse(string sourceCode) { @@ -33,32 +221,86 @@ public BlockScriptParseResult Parse(string sourceCode) try { - // 1. Parse to syntax tree - var syntaxTree = CSharpSyntaxTree.ParseText(sourceCode); - var root = syntaxTree.GetRoot(); + Log.Debug("[BlockScriptParser] Parse called with {LineCount} lines of code", sourceCode.Split('\n').Length); - // 2. Validate prohibited syntax - var validator = new BlockScriptValidator(); - validator.Visit(root); + // Phase 1: Recognize block structure using custom DSL parser + var recognizer = new BlockStructureRecognizer(sourceCode); + recognizer.RecognizeBlocks(); - if (!validator.IsValid) + if (recognizer.HasErrors) { return new BlockScriptParseResult { IsSuccess = false, - ErrorMessage = validator.ErrorMessage, - ErrorLine = validator.ErrorLine + ErrorMessage = recognizer.ErrorMessage, + ErrorLine = recognizer.ErrorLine }; } - // 3. Extract blocks - var extractor = new BlockExtractor(); - extractor.Visit(root); + Log.Debug("[BlockScriptParser] Recognized {Count} blocks:", recognizer.Blocks.Count); + foreach (var block in recognizer.Blocks) + { + Log.Debug(" {Type} '{Name}' at line {Line}, content length: {Len}", + block.BlockType, block.BlockName, block.StartLine, block.Content.Length); + } + + // Phase 2: Build BlockScript from recognized blocks + var script = new BlockScript(); + + foreach (var recognized in recognizer.Blocks) + { + // Validate and parse the pure C# code for this block + var validationResult = ValidatePureBlockCode(recognized); + if (!validationResult.IsValid) + { + return new BlockScriptParseResult + { + IsSuccess = false, + ErrorMessage = validationResult.ErrorMessage, + ErrorLine = recognized.StartLine + validationResult.ErrorLine + }; + } + + // Create BlockDefinition from recognized block + var blockDef = CreateBlockDefinition(recognized, validationResult); + + // Add to appropriate slot in script + switch (recognized.BlockType) + { + case BlockType.ConstBlock: + script.ConstBlock = blockDef; + break; + case BlockType.PubVarBlock: + script.PubVarBlock = blockDef; + break; + case BlockType.MainBlock: + script.MainBlock = blockDef; + break; + case BlockType.NamedBlock: + script.NamedBlocks[recognized.BlockName] = blockDef; + break; + } + + script.AllBlocks.Add(blockDef); + + // Check if this block contains Loop statements and create LoopBlocks + // This allows LoopBodyEnd to return to the LoopBlock (re-evaluate condition) + // instead of returning to the parent block (which would re-execute the whole block) + // Note: With the simplified design, Loop statements are kept as ExpressionStatements + // in the parent block, but we still need LoopBlocks for LoopBodyEnd's return point + if (blockDef.Type == BlockType.MainBlock || blockDef.Type == BlockType.NamedBlock) + { + CreateLoopBlocksForBlock(blockDef, script); + } + } + + // Phase 3: Link blocks sequentially + BlockLinker.LinkBlocksSequentially(script); - // 4. Build and return block script - var script = extractor.BuildBlockScript(); script.SourceCode = sourceCode; + Log.Information("[BlockScriptParser] Successfully parsed {BlockCount} blocks", script.AllBlocks.Count); + return new BlockScriptParseResult { IsSuccess = true, @@ -78,498 +320,647 @@ public BlockScriptParseResult Parse(string sourceCode) } /// - /// Parses a block-based script from source code asynchronously + /// Validates pure C# code within a block using Roslyn /// - public Task ParseAsync(string sourceCode) + private BlockValidationResult ValidatePureBlockCode(RecognizedBlock recognized) { - return Task.FromResult(Parse(sourceCode)); - } + var result = new BlockValidationResult { IsValid = true }; - /// - /// Validates block script syntax and structure - /// - public BlockScriptValidationResult Validate(string sourceCode) - { - var result = new BlockScriptValidationResult { IsValid = true }; - - if (string.IsNullOrWhiteSpace(sourceCode)) + if (string.IsNullOrWhiteSpace(recognized.Content)) { - result.IsValid = false; - result.AddError("Source code is empty"); - return result; + return result; // Empty blocks are OK } try { - var syntaxTree = CSharpSyntaxTree.ParseText(sourceCode); + // Parse the pure C# code + var syntaxTree = CSharpSyntaxTree.ParseText(recognized.Content); var root = syntaxTree.GetRoot(); + result.ParsedRoot = root; // Save for reuse by CreateBlockDefinition + var diagnostics = syntaxTree.GetDiagnostics(); - var validator = new BlockScriptValidator(); - validator.Visit(root); - - if (!validator.IsValid) + // Check for syntax errors + foreach (var diag in diagnostics) { - result.IsValid = false; - result.AddError(validator.ErrorMessage); + if (diag.Severity == Microsoft.CodeAnalysis.DiagnosticSeverity.Error) + { + result.IsValid = false; + result.ErrorMessage = $"[{recognized.BlockType}] Syntax error: {diag.GetMessage()}"; + result.ErrorLine = recognized.StartLine + (int)diag.Location.GetLineSpan().StartLinePosition.Line; + return result; + } } - // Add warnings - foreach (var warning in validator.Warnings) - { - result.AddWarning(warning); - } + // Validate block-type-specific rules + ValidateBlockSpecificRules(recognized, root, result); + + return result; } catch (Exception ex) { result.IsValid = false; - result.AddError($"Validation error: {ex.Message}"); + result.ErrorMessage = $"[{recognized.BlockType}] Parse error: {ex.Message}"; + result.ErrorLine = recognized.StartLine; + return result; } - - return result; } -} - -/// -/// Block script syntax validator - validates prohibited syntax in each block type -/// -internal class BlockScriptValidator : CSharpSyntaxWalker -{ - public bool IsValid { get; private set; } = true; - public string ErrorMessage { get; private set; } = string.Empty; - public int ErrorLine { get; private set; } - public List Warnings { get; } = []; - - private BlockType _currentBlockType = BlockType.NamedBlock; - private string _currentBlockName = string.Empty; - private bool _inConstBlock = false; - private bool _inPubVarBlock = false; - public override void VisitAttribute(AttributeSyntax node) + /// + /// Validates block-type-specific rules + /// + private void ValidateBlockSpecificRules(RecognizedBlock recognized, SyntaxNode root, BlockValidationResult result) { - var attrName = node.Name.ToString(); + // Get all statements in the block + var statements = root.DescendantNodes() + .Where(n => n is StatementSyntax) + .Cast() + .ToList(); - if (attrName.Contains("ConstBlock")) - { - _inConstBlock = true; - _inPubVarBlock = false; - _currentBlockType = BlockType.ConstBlock; - } - else if (attrName.Contains("PubVarBlock")) - { - _inConstBlock = false; - _inPubVarBlock = true; - _currentBlockType = BlockType.PubVarBlock; - } - else if (attrName.Contains("MainBlock")) - { - _inConstBlock = false; - _inPubVarBlock = false; - _currentBlockType = BlockType.MainBlock; - } - else if (attrName.Contains("Block")) + foreach (var stmt in statements) { - _inConstBlock = false; - _inPubVarBlock = false; - _currentBlockType = BlockType.NamedBlock; - - // Extract block name from [Block="Name"] - var nameArg = node.ArgumentList?.Arguments.FirstOrDefault(); - if (nameArg != null) + switch (recognized.BlockType) { - _currentBlockName = nameArg.Expression.GetText().ToString().Trim('"'); + case BlockType.ConstBlock: + case BlockType.PubVarBlock: + // These blocks should only have variable declarations + if (stmt is not LocalDeclarationStatementSyntax) + { + result.IsValid = false; + result.ErrorMessage = $"[{recognized.BlockType}] Only variable declarations are allowed. Found: {stmt.Kind()}"; + result.ErrorLine = recognized.StartLine + stmt.GetLineNumber(); + return; + } + break; + + case BlockType.MainBlock: + case BlockType.NamedBlock: + // These blocks can have any valid C# statement + // But we should check for prohibited constructs like if/else/for/while + if (ContainsProhibitedSyntax(stmt)) + { + result.IsValid = false; + result.ErrorMessage = $"[{recognized.BlockType}] Prohibited syntax found: if/else/for/while/try/catch are not allowed"; + result.ErrorLine = recognized.StartLine + stmt.GetLineNumber(); + return; + } + break; } } - - base.VisitAttribute(node); - } - - public override void VisitGlobalStatement(GlobalStatementSyntax node) - { - ValidateStatement(node.Statement); - base.VisitGlobalStatement(node); } - private void ValidateStatement(StatementSyntax statement) + /// + /// Checks if a statement contains prohibited syntax (if/else/for/while/try/catch) + /// + private bool ContainsProhibitedSyntax(StatementSyntax statement) { - // For ConstBlock: only variable declarations allowed - if (_inConstBlock) - { - if (statement is not LocalDeclarationStatementSyntax) + // Check the statement itself + if (statement is IfStatementSyntax || + statement is ForStatementSyntax || + statement is ForEachStatementSyntax || + statement is WhileStatementSyntax || + statement is DoStatementSyntax || + statement is TryStatementSyntax) + { + return true; + } + + // Recursively check child statements + foreach (var child in statement.DescendantNodes()) + { + if (child is IfStatementSyntax || + child is ForStatementSyntax || + child is ForEachStatementSyntax || + child is WhileStatementSyntax || + child is DoStatementSyntax || + child is TryStatementSyntax) { - Fail($"ConstBlock only allows variable declarations", statement); - return; + return true; } } - // For PubVarBlock: only variable declarations and assignments allowed - else if (_inPubVarBlock) - { - if (statement is LocalDeclarationStatementSyntax) - { - // OK - variable declaration - } - else if (statement is ExpressionStatementSyntax exprStmt) - { - // Check if it's an assignment - if (exprStmt.Expression is not AssignmentExpressionSyntax) - { - Fail($"PubVarBlock only allows variable declarations and assignments", statement); - return; - } - } - else - { - Fail($"PubVarBlock only allows variable declarations and assignments", statement); - return; - } - } - // For MainBlock and NamedBlock: validate allowed syntax - else - { - ValidateMainBlockStatement(statement); - } + + return false; } - private void ValidateMainBlockStatement(StatementSyntax statement) + /// + /// Creates a BlockDefinition from a recognized block and its validation result + /// + private BlockDefinition CreateBlockDefinition(RecognizedBlock recognized, BlockValidationResult validationResult) { - // Allowed: LocalDeclaration, ExpressionStatement, ReturnStatement - if (statement is LocalDeclarationStatementSyntax) - { - // OK - variable declaration - } - else if (statement is ExpressionStatementSyntax) - { - // OK - expression statement (function call, assignment, etc.) - } - else if (statement is ReturnStatementSyntax) - { - // OK - return statement - } - else if (statement is IfStatementSyntax) - { - Fail("if statements are not allowed. Use Branch() function instead.", statement); - } - else if (statement is WhileStatementSyntax) + // For ConstBlock/PubVarBlock/MainBlock, use default names if BlockName is empty + var blockName = recognized.BlockName; + if (string.IsNullOrWhiteSpace(blockName)) { - Fail("while statements are not allowed. Use Loop() function instead.", statement); - } - else if (statement is ForStatementSyntax) - { - Fail("for statements are not allowed.", statement); - } - else if (statement is ForEachStatementSyntax) - { - Fail("foreach statements are not allowed.", statement); - } - else if (statement is SwitchStatementSyntax) - { - Fail("switch statements are not allowed.", statement); - } - else if (statement is TryStatementSyntax) - { - Fail("try-catch statements are not allowed.", statement); - } - else if (statement is ThrowStatementSyntax) - { - Fail("throw statements are not allowed.", statement); + blockName = recognized.BlockType switch + { + BlockType.ConstBlock => "ConstBlock", + BlockType.PubVarBlock => "PubVarBlock", + BlockType.MainBlock => "MainBlock", + _ => blockName + }; } - else if (statement is UsingStatementSyntax) + + var blockDef = new BlockDefinition { - Fail("using statements are not allowed.", statement); - } - else if (statement is LockStatementSyntax) + Type = recognized.BlockType, + Name = blockName, + LineNumber = recognized.StartLine + }; + + if (string.IsNullOrWhiteSpace(recognized.Content)) { - Fail("lock statements are not allowed.", statement); + return blockDef; } - else if (statement is YieldStatementSyntax) + + // Use the pre-parsed root from validation + var root = validationResult.ParsedRoot; + if (root == null) { - Fail("yield statements are not allowed.", statement); + // Fallback: parse if not available (should not happen in normal flow) + var syntaxTree = CSharpSyntaxTree.ParseText(recognized.Content); + root = syntaxTree.GetRoot(); } - else if (statement is GotoStatementSyntax) + + // Extract variable declarations for ConstBlock and PubVarBlock + if (recognized.BlockType == BlockType.ConstBlock || recognized.BlockType == BlockType.PubVarBlock) { - Fail("goto statements are not allowed.", statement); + var varDeclarations = root.DescendantNodes() + .OfType(); + + foreach (var varDecl in varDeclarations) + { + foreach (var variable in varDecl.Declaration.Variables) + { + var varDefinition = new Contract.Workflow.VariableDeclaration + { + Name = variable.Identifier.Text, + Type = varDecl.Declaration.Type.ToString(), + InitialValueExpression = variable.Initializer?.Value?.ToString() + }; + + // Pre-evaluate constant values + if ((recognized.BlockType == BlockType.ConstBlock || + recognized.BlockType == BlockType.PubVarBlock) && + variable.Initializer?.Value is LiteralExpressionSyntax literal) + { + varDefinition.DefaultValue = GetLiteralValue(literal); + } + + blockDef.Variables.Add(varDefinition); + } + } } - else + + // Extract statements for MainBlock and NamedBlock + if (recognized.BlockType == BlockType.MainBlock || recognized.BlockType == BlockType.NamedBlock) { - // Allow other statement types but warn - Warnings.Add($"Unexpected statement type at line {GetLineNumber(statement)}: {statement.Kind()}"); + ExtractStatements(root, blockDef, recognized.BlockType); } - } - private void Fail(string message, StatementSyntax statement) - { - IsValid = false; - ErrorMessage = $"[{_currentBlockType}] {message}"; - ErrorLine = GetLineNumber(statement); + return blockDef; } - private int GetLineNumber(SyntaxNode node) + /// + /// Extracts statements from syntax root into block definition + /// + private void ExtractStatements(SyntaxNode root, BlockDefinition block, BlockType blockType) { - var location = node.GetLocation(); - var lineSpan = location.GetLineSpan(); - return lineSpan.StartLinePosition.Line + 1; - } -} - -/// -/// Block extractor - extracts blocks from syntax tree -/// -internal class BlockExtractor : CSharpSyntaxWalker -{ - private BlockDefinition? _currentBlock; - private BlockScript _script = new(); - private BlockType _currentBlockType = BlockType.NamedBlock; - private string _currentBlockName = string.Empty; - private bool _inConstBlock = false; - private bool _inPubVarBlock = false; + // First, let's see the raw content being parsed + var rawContent = root.ToFullString(); + Log.Debug("[BlockScriptParser] ExtractStatements: Block '{BlockName}' raw content ({Len} chars):\n{RawContent}", + block.Name, rawContent.Length, rawContent); - public BlockScript BuildBlockScript() => _script; + // Also log all descendant nodes to understand the syntax tree structure + var nodeTypes = root.DescendantNodes().Select(n => n.GetType().Name).Distinct().ToList(); + Log.Debug("[BlockScriptParser] ExtractStatements: Node types in tree: {NodeTypes}", + string.Join(", ", nodeTypes)); - public override void VisitAttribute(AttributeSyntax node) - { - var attrName = node.Name.ToString(); - - if (attrName.Contains("ConstBlock")) - { - StartBlock(BlockType.ConstBlock, "ConstBlock"); - _inConstBlock = true; - _inPubVarBlock = false; - } - else if (attrName.Contains("PubVarBlock")) - { - StartBlock(BlockType.PubVarBlock, "PubVarBlock"); - _inConstBlock = false; - _inPubVarBlock = true; - } - else if (attrName.Contains("MainBlock")) + // Log all ExpressionStatementSyntax nodes found + var exprStatements = root.DescendantNodes().OfType().ToList(); + Log.Debug("[BlockScriptParser] ExtractStatements: Found {Count} ExpressionStatementSyntax nodes:", exprStatements.Count); + foreach (var es in exprStatements) { - StartBlock(BlockType.MainBlock, "MainBlock"); - _inConstBlock = false; - _inPubVarBlock = false; + Log.Debug(" - Expression type: {ExprType}, Text: {Text}", es.Expression.GetType().Name, es.Expression.ToString()); } - else if (attrName.Contains("Block")) + + foreach (var node in root.DescendantNodes()) { - // Extract block name from [Block="Name"] - var nameArg = node.ArgumentList?.Arguments.FirstOrDefault(); - var blockName = "NamedBlock"; - if (nameArg != null) + if (node is LocalDeclarationStatementSyntax varDecl) { - blockName = nameArg.Expression.GetText().ToString().Trim('"'); + foreach (var variable in varDecl.Declaration.Variables) + { + var varDefinition = new Contract.Workflow.VariableDeclaration + { + Name = variable.Identifier.Text, + Type = varDecl.Declaration.Type.ToString(), + InitialValueExpression = variable.Initializer?.Value?.ToString() + }; + + if (variable.Initializer?.Value is LiteralExpressionSyntax literal) + { + varDefinition.DefaultValue = GetLiteralValue(literal); + } + + block.Variables.Add(varDefinition); + } } - StartBlock(BlockType.NamedBlock, blockName); - _inConstBlock = false; - _inPubVarBlock = false; - } + else if (node is ExpressionStatementSyntax exprStmt) + { + var exprText = exprStmt.Expression.ToString(); - base.VisitAttribute(node); - } + // Log ALL expression statements for debugging + Log.Debug("[BlockScriptParser] ExtractStatements: Processing ExpressionStatementSyntax: Type={ExprType}, Text={ExprText}", + exprStmt.Expression.GetType().Name, exprText); - public override void VisitGlobalStatement(GlobalStatementSyntax node) - { - if (_currentBlock == null) - { - // Statements outside of any block - add to MainBlock if exists, otherwise create one - if (_script.MainBlock == null) + // Check for Branch/Loop/LoopBodyEnd in two patterns: + // 1. Direct call: Loop(...) or Branch(...) + // 2. Assignment: NextBlock = Loop(...) or NextBlock = Branch(...) + if (exprStmt.Expression is InvocationExpressionSyntax invoke) + { + var methodName = GetMethodName(invoke); + + if (methodName == "Branch") + { + // Pass full expression text for assignment case + block.Statements.Add(CreateFlowControlStatement(invoke, FlowControlType.Branch, exprStmt.GetLineNumber(), exprText)); + } + else if (methodName == "Loop") + { + // Pass full expression text for assignment case + block.Statements.Add(CreateFlowControlStatement(invoke, FlowControlType.Loop, exprStmt.GetLineNumber(), exprText)); + } + else if (methodName == "LoopBodyEnd") + { + // Pass full expression text for assignment case + block.Statements.Add(CreateFlowControlStatement(invoke, FlowControlType.LoopBodyEnd, exprStmt.GetLineNumber(), exprText)); + } + else + { + // Regular expression statement + block.Statements.Add(new ExpressionStatement + { + LineNumber = exprStmt.GetLineNumber(), + SourceCode = exprText, + Expression = exprText + }); + } + } + else if (exprStmt.Expression is AssignmentExpressionSyntax assignment) + { + // Handle NextBlock = Loop(...) or NextBlock = Branch(...) patterns + Log.Debug("[BlockScriptParser] Processing AssignmentExpressionSyntax: {ExprText}", exprText); + if (assignment.Right is InvocationExpressionSyntax assignInvoke) + { + var methodName = GetMethodName(assignInvoke); + Log.Debug("[BlockScriptParser] assignment.Right is InvocationExpressionSyntax, methodName = {MethodName}", methodName); + + if (methodName == "Branch") + { + // Pass full expression text so executor runs: NextBlock = Branch(...) + block.Statements.Add(CreateFlowControlStatement(assignInvoke, FlowControlType.Branch, exprStmt.GetLineNumber(), exprText)); + } + else if (methodName == "Loop") + { + // Pass full expression text so executor runs: NextBlock = Loop(...) + block.Statements.Add(CreateFlowControlStatement(assignInvoke, FlowControlType.Loop, exprStmt.GetLineNumber(), exprText)); + } + else if (methodName == "LoopBodyEnd") + { + // Pass full expression text so executor runs: NextBlock = LoopBodyEnd(...) + block.Statements.Add(CreateFlowControlStatement(assignInvoke, FlowControlType.LoopBodyEnd, exprStmt.GetLineNumber(), exprText)); + } + else + { + Log.Debug("[BlockScriptParser] Unknown methodName '{MethodName}', treating as ExpressionStatement", methodName); + // Regular expression statement + block.Statements.Add(new ExpressionStatement + { + LineNumber = exprStmt.GetLineNumber(), + SourceCode = exprText, + Expression = exprText + }); + } + } + else + { + Log.Debug("[BlockScriptParser] assignment.Right is NOT InvocationExpressionSyntax, type = {Type}", assignment.Right.GetType().Name); + // Regular expression statement + block.Statements.Add(new ExpressionStatement + { + LineNumber = exprStmt.GetLineNumber(), + SourceCode = exprText, + Expression = exprText + }); + } + } + else + { + // Log what type this expression actually is + Log.Debug("[BlockScriptParser] Unhandled expression type in {BlockType}: {Type} = {Expr}", + blockType, exprStmt.Expression.GetType().Name, exprText); + block.Statements.Add(new ExpressionStatement + { + LineNumber = exprStmt.GetLineNumber(), + SourceCode = exprText, + Expression = exprText + }); + } + } + else if (node is ReturnStatementSyntax returnStmt) { - _script.MainBlock = new BlockDefinition + block.Statements.Add(new FlowControlStatement { - Type = BlockType.MainBlock, - Name = "MainBlock", - LineNumber = GetLineNumber(node) - }; - _script.AllBlocks.Add(_script.MainBlock); + LineNumber = returnStmt.GetLineNumber(), + SourceCode = returnStmt.ToFullString(), + ControlType = FlowControlType.Return, + ConditionExpression = returnStmt.Expression?.ToString() ?? string.Empty + }); } - _currentBlock = _script.MainBlock; } - - ExtractStatement(node.Statement, _currentBlock); - base.VisitGlobalStatement(node); } - private void StartBlock(BlockType type, string name) + private string GetMethodName(InvocationExpressionSyntax invoke) { - _currentBlockType = type; - _currentBlockName = name; + if (invoke.Expression is IdentifierNameSyntax identifier) + return identifier.Identifier.Text; + if (invoke.Expression is MemberAccessExpressionSyntax member) + return member.Name.Identifier.Text; + return string.Empty; + } - _currentBlock = new BlockDefinition + private FlowControlStatement CreateFlowControlStatement(InvocationExpressionSyntax invoke, FlowControlType type, int baseLine, string? fullExpressionText = null) + { + var args = invoke.ArgumentList.Arguments; + var statement = new FlowControlStatement { - Type = type, - Name = name, - LineNumber = GetLineNumber(GetCurrentAttributeSyntax()) + LineNumber = baseLine, + // Use full expression text if provided (for NextBlock = Loop(...) case) + // Otherwise use just the invocation (for direct Loop() call case) + SourceCode = fullExpressionText ?? invoke.ToFullString(), + ControlType = type }; + Log.Debug("[BlockScriptParser] CreateFlowControlStatement: type={Type}, SourceCode={SourceCode}", + type, statement.SourceCode); + switch (type) { - case BlockType.ConstBlock: - _script.ConstBlock = _currentBlock; - break; - case BlockType.PubVarBlock: - _script.PubVarBlock = _currentBlock; - break; - case BlockType.MainBlock: - _script.MainBlock = _currentBlock; + case FlowControlType.Branch: + case FlowControlType.Loop: + if (args.Count >= 1) + { + statement.ConditionExpression = args[0].Expression.ToString(); + Log.Debug("[BlockScriptParser] args[0] (condition): {Expr}", args[0].Expression.ToString()); + } + if (args.Count >= 2) + { + statement.TrueBlockName = GetStringLiteral(args[1].Expression); + Log.Debug("[BlockScriptParser] args[1] (trueBlock): raw={Raw}, extracted={Extracted}", + args[1].Expression.ToString(), statement.TrueBlockName); + } + if (args.Count >= 3) + { + statement.FalseBlockName = GetStringLiteral(args[2].Expression); + Log.Debug("[BlockScriptParser] args[2] (falseBlock): raw={Raw}, extracted={Extracted}", + args[2].Expression.ToString(), statement.FalseBlockName); + } + Log.Debug("[BlockScriptParser] Loop/Branch created: Condition={Condition}, TrueBlock={TrueBlock}, FalseBlock={FalseBlock}", + statement.ConditionExpression, statement.TrueBlockName, statement.FalseBlockName); break; - case BlockType.NamedBlock: - _script.NamedBlocks[name] = _currentBlock; + + case FlowControlType.LoopBodyEnd: + if (args.Count >= 1) + statement.LoopBodyEndReturnTo = GetStringLiteral(args[0].Expression); break; } - _script.AllBlocks.Add(_currentBlock); + return statement; } - private AttributeSyntax? _lastAttribute; + /// + /// Creates LoopBlocks for blocks containing Loop statements. + /// LoopBlock is used as a "re-entry point" for loop condition re-evaluation. + /// + /// Problem: When LoopBodyEnd("MainBlock") returns to MainBlock, MainBlock re-executes + /// all its statements (including those before the Loop statement). + /// + /// Solution: Create a LoopBlock for each block containing a Loop statement. + /// - Parent block's NextBlockName is set to the LoopBlock (because Loop terminates the block) + /// - LoopBlock contains only the Loop statement (as a FlowControlStatement) + /// - Loop statements are removed from parent block (they act as block terminators) + /// - When LoopBodyEnd returns to the parent block, execution goes to LoopBlock instead + /// - LoopBlock re-executes the Loop statement to re-evaluate the condition + /// + private void CreateLoopBlocksForBlock(BlockDefinition block, BlockScript script) + { + // Find all Loop statements in this block + var loopStatements = block.Statements + .OfType() + .Where(fs => fs.ControlType == FlowControlType.Loop) + .ToList(); + + if (loopStatements.Count == 0) return; + + // Create a LoopBlock for the FIRST Loop statement + // Note: The LoopBlock represents the "re-entry point" for loop condition re-evaluation + var firstLoop = loopStatements[0]; + var loopBlockName = $"{block.Name}_Loop"; + + // Create the LoopBlock + var loopBlock = new BlockDefinition + { + Type = BlockType.LoopBlock, + Name = loopBlockName, + ParentBlockName = block.Name, + LineNumber = firstLoop.LineNumber + }; + + // The LoopBlock's NextBlockName should be the Loop's falseBlock (loop exit) + // This is where execution goes after the loop finishes + loopBlock.NextBlockName = firstLoop.FalseBlockName; + + // Add a copy of the Loop statement to the LoopBlock + var loopBlockStatement = new FlowControlStatement + { + LineNumber = firstLoop.LineNumber, + SourceCode = firstLoop.SourceCode, + ControlType = FlowControlType.Loop, + ConditionExpression = firstLoop.ConditionExpression, + TrueBlockName = firstLoop.TrueBlockName, + FalseBlockName = firstLoop.FalseBlockName + }; + loopBlock.Statements.Add(loopBlockStatement); + + // Add LoopBlock to script + script.NamedBlocks[loopBlockName] = loopBlock; + script.AllBlocks.Add(loopBlock); + script.LoopBlocks[block.Name] = loopBlock; // Key is parent block name, value is LoopBlock + + // CRITICAL: Set parent block's NextBlockName to LoopBlock! + // Because Loop acts as a block terminator, the parent's "next block" is the LoopBlock. + // The BlockLinker will NOT override this because it checks !string.IsNullOrEmpty(). + block.NextBlockName = loopBlockName; + + // Remove Loop statements from parent block since they act as terminators + foreach (var loopStmt in loopStatements) + { + block.Statements.Remove(loopStmt); + } + + Log.Debug("[BlockScriptParser] Created LoopBlock '{LoopBlockName}' for parent '{ParentName}', " + + "parent NextBlock -> '{LoopBlockName}', Loop jumps to {TrueBlock}/{FalseBlock}", + loopBlockName, block.Name, loopBlockName, + firstLoop.TrueBlockName, firstLoop.FalseBlockName); + } - private AttributeSyntax? GetCurrentAttributeSyntax() + private string GetStringLiteral(ExpressionSyntax expr) { - return _lastAttribute; + if (expr is LiteralExpressionSyntax literal) + return literal.Token.ValueText; + return expr.ToString().Trim('"'); } - public override void VisitAttributeList(AttributeListSyntax node) + private object? GetLiteralValue(LiteralExpressionSyntax literal) { - _lastAttribute = node.Attributes.FirstOrDefault(); - base.VisitAttributeList(node); + return literal.Token.Value; } - private void ExtractStatement(StatementSyntax statement, BlockDefinition block) + private class BlockValidationResult { - var lineNumber = GetLineNumber(statement); - var sourceCode = statement.ToFullString(); + public bool IsValid { get; set; } = true; + public string ErrorMessage { get; set; } = string.Empty; + public int ErrorLine { get; set; } + public SyntaxNode? ParsedRoot { get; set; } + } - if (statement is LocalDeclarationStatementSyntax localDecl) + /// + /// Parses a block-based script from source code asynchronously + /// + public Task ParseAsync(string sourceCode) + { + return Task.FromResult(Parse(sourceCode)); + } + + /// + /// Validates block script syntax and structure using the new DSL recognizer + /// + public BlockScriptValidationResult Validate(string sourceCode) + { + var result = new BlockScriptValidationResult { IsValid = true }; + + if (string.IsNullOrWhiteSpace(sourceCode)) { - foreach (var declarator in localDecl.Declaration.Variables) - { - var varDecl = new Contract.Workflow.VariableDeclaration - { - Name = declarator.Identifier.Text, - Type = localDecl.Declaration.Type.ToString(), - InitialValueExpression = declarator.Initializer?.Value?.ToString() - }; + result.IsValid = false; + result.AddError("Source code is empty"); + return result; + } - // Pre-evaluate constant values - if (_inConstBlock && declarator.Initializer?.Value is LiteralExpressionSyntax literal) - { - varDecl.DefaultValue = GetLiteralValue(literal); - } + try + { + // Log the full source code for debugging + var codePreview = sourceCode.Length > 2000 ? sourceCode.Substring(0, 2000) + "..." : sourceCode; + Log.Information("[BlockScriptParser] Validating block script with new DSL syntax. Full source code:\n{Code}", codePreview); + + // Use the new BlockStructureRecognizer for validation + var recognizer = new BlockStructureRecognizer(sourceCode); + recognizer.RecognizeBlocks(); - block.Variables.Add(varDecl); + if (recognizer.HasErrors) + { + result.IsValid = false; + result.AddError(recognizer.ErrorMessage); + return result; } - block.Statements.Add(new Contract.Workflow.VariableDeclarationStatement + // Log recognized blocks + Log.Debug("[BlockScriptParser] Validate: Recognized {Count} blocks:", recognizer.Blocks.Count); + foreach (var block in recognizer.Blocks) { - LineNumber = lineNumber, - SourceCode = sourceCode, - Declaration = block.Variables.Last() - }); - } - else if (statement is ExpressionStatementSyntax exprStmt) - { - var exprText = exprStmt.Expression.ToString(); + Log.Debug(" {Type} '{Name}' at line {Line}", block.BlockType, block.BlockName, block.StartLine); + } - // Check if this is a Branch/Loop call - if (exprStmt.Expression is InvocationExpressionSyntax invoke) + // Validate that MainBlock exists + if (!recognizer.Blocks.Any(b => b.BlockType == BlockType.MainBlock)) { - var methodName = GetMethodName(invoke); - if (methodName == "Branch") - { - ExtractBranchStatement(invoke, block, lineNumber, sourceCode); - return; - } - else if (methodName == "Loop") + result.IsValid = false; + result.AddError("Script must have a #MainBlock"); + return result; + } + + // Validate each block's pure C# code + foreach (var recognized in recognizer.Blocks) + { + if (string.IsNullOrWhiteSpace(recognized.Content)) + continue; + + // Validate the pure C# code using Roslyn + var validationResult = ValidatePureBlockCode(recognized); + if (!validationResult.IsValid) { - ExtractLoopStatement(invoke, block, lineNumber, sourceCode); - return; + result.IsValid = false; + result.AddError(validationResult.ErrorMessage); + return result; } } - block.Statements.Add(new ExpressionStatement - { - LineNumber = lineNumber, - SourceCode = sourceCode, - Expression = exprText - }); - } - else if (statement is ReturnStatementSyntax returnStmt) - { - block.Statements.Add(new FlowControlStatement - { - LineNumber = lineNumber, - SourceCode = sourceCode, - ControlType = FlowControlType.Return, - ConditionExpression = returnStmt.Expression?.ToString() ?? string.Empty - }); + return result; } - else + catch (Exception ex) { - // Generic statement - store as expression - block.Statements.Add(new ExpressionStatement - { - LineNumber = lineNumber, - SourceCode = sourceCode, - Expression = sourceCode - }); + result.IsValid = false; + result.AddError($"Validation error: {ex.Message}"); + Log.Error(ex, "[BlockScriptParser] Exception during validation"); + return result; } } +} - private void ExtractBranchStatement(InvocationExpressionSyntax invoke, BlockDefinition block, int lineNumber, string sourceCode) +/// +/// Links blocks sequentially for natural fallthrough execution flow +/// +internal static class BlockLinker +{ + public static void LinkBlocksSequentially(BlockScript script) { - var args = invoke.ArgumentList.Arguments; - var flowControl = new FlowControlStatement - { - LineNumber = lineNumber, - SourceCode = sourceCode, - ControlType = FlowControlType.Branch - }; - - if (args.Count >= 1) - flowControl.ConditionExpression = args[0].Expression.ToString(); - if (args.Count >= 2) - flowControl.TrueBlockName = GetStringLiteral(args[1].Expression); - if (args.Count >= 3) - flowControl.FalseBlockName = GetStringLiteral(args[2].Expression); - - block.Statements.Add(flowControl); - } + var blocks = script.AllBlocks; - private void ExtractLoopStatement(InvocationExpressionSyntax invoke, BlockDefinition block, int lineNumber, string sourceCode) - { - var args = invoke.ArgumentList.Arguments; - var flowControl = new FlowControlStatement + for (int i = 0; i < blocks.Count - 1; i++) { - LineNumber = lineNumber, - SourceCode = sourceCode, - ControlType = FlowControlType.Loop - }; - - if (args.Count >= 1) - flowControl.ConditionExpression = args[0].Expression.ToString(); - if (args.Count >= 2) - flowControl.LoopBlockName = GetStringLiteral(args[1].Expression); - - block.Statements.Add(flowControl); - } + var currentBlock = blocks[i]; + var nextBlock = blocks[i + 1]; - private string GetMethodName(InvocationExpressionSyntax invoke) - { - if (invoke.Expression is IdentifierNameSyntax identifier) - return identifier.Identifier.Text; - if (invoke.Expression is MemberAccessExpressionSyntax member) - return member.Name.Identifier.Text; - return string.Empty; - } + // Skip if already has explicit control flow target + if (!string.IsNullOrEmpty(currentBlock.NextBlockName)) + { + Log.Debug("[BlockLinker] Block '{BlockName}' already has NextBlockName={NextBlock}, skipping", + currentBlock.Name, currentBlock.NextBlockName); + continue; + } - private string GetStringLiteral(ExpressionSyntax expr) - { - if (expr is LiteralExpressionSyntax literal && literal.Kind() == SyntaxKind.StringLiteralExpression) - return literal.Token.ValueText; - return expr.ToString().Trim('"'); - } + // Set sequential fallthrough + currentBlock.NextBlockName = nextBlock.Name; + Log.Debug("[BlockLinker] Linked '{FromBlock}' -> '{ToBlock}' (sequential)", + currentBlock.Name, nextBlock.Name); + } - private object? GetLiteralValue(LiteralExpressionSyntax literal) - { - return literal.Token.Value; + // Log final block (should have empty NextBlockName to signal end) + var lastBlock = blocks.LastOrDefault(); + if (lastBlock != null) + { + Log.Debug("[BlockLinker] Last block is '{BlockName}', NextBlockName={NextBlock} (should be empty for script end)", + lastBlock.Name, lastBlock.NextBlockName ?? "(null)"); + } } +} - private int GetLineNumber(SyntaxNode node) +/// +/// Shared Roslyn syntax extensions for workflow parsing +/// +internal static class RoslynExtensions +{ + /// + /// Gets the 1-based line number for a syntax node + /// + public static int GetLineNumber(this SyntaxNode node) { var location = node.GetLocation(); var lineSpan = location.GetLineSpan(); diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltInFunctions.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltInFunctions.cs deleted file mode 100644 index f4f44a7b..00000000 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltInFunctions.cs +++ /dev/null @@ -1,192 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.CodeAnalysis.CSharp.Scripting; -using Microsoft.CodeAnalysis.CSharp.Syntax; -using Microsoft.CodeAnalysis.Scripting; -using Microsoft.CodeAnalysis; -using KitX.Core.Contract.Workflow; - -namespace KitX.Core.Workflow.BlockScripting; - -/// -/// Built-in functions for block scripts - these functions are injected into the script execution context -/// -public static class BuiltInFunctions -{ - /// - /// Gets the workflow output for collecting Print() output - /// - private static List _output { get; } = new(); - - /// - /// Condition branch - triggers jump to target block without returning a value - /// - /// - /// This function throws a BranchException which is caught by the execution engine - /// to implement block jumping. The condition determines which branch to take. - /// - /// Boolean condition - /// Block name to jump to when condition is true - /// Block name to jump to when condition is false - public static void Branch(bool condition, string trueBlock, string falseBlock) - { - var targetBlock = condition ? trueBlock : falseBlock; - throw new BranchException(targetBlock); - } - - /// - /// Loop - triggers jump back to a block while condition is true - /// - /// Boolean condition - /// Block name to jump to while condition is true - public static void Loop(bool condition, string loopBlock) - { - if (condition) - throw new BranchException(loopBlock); - // Condition is false, continue to next statement - } - - /// - /// Pause execution for specified milliseconds (for debugging) - /// - /// Milliseconds to sleep - public static void Pause(int milliseconds) - { - Thread.Sleep(milliseconds); - } - - /// - /// Print a value to workflow output - /// - /// Value to print - public static void Print(object? value) - { - WorkflowOutput.WriteLine(value); - _output.Add(value?.ToString() ?? "null"); - } - - /// - /// Clears the accumulated output - /// - public static void ClearOutput() - { - _output.Clear(); - } - - /// - /// Gets all accumulated output - /// - public static IReadOnlyList GetOutput() => _output.AsReadOnly(); -} - -/// -/// Branch exception - used by execution engine to trigger block jumps -/// -public class BranchException : Exception -{ - /// - /// Target block name to jump to - /// - public string TargetBlock { get; } - - /// - /// Creates a new branch exception - /// - /// Target block name - public BranchException(string targetBlock) : base($"Branch to {targetBlock}") - { - TargetBlock = targetBlock; - } -} - -/// -/// Return exception - used by execution engine to return from script -/// -public class ReturnException : Exception -{ - /// - /// Return value - /// - public object? Value { get; } - - /// - /// Creates a new return exception - /// - /// Return value - public ReturnException(object? value) : base("Return from script") - { - Value = value; - } -} - -/// -/// Break exception - used by execution engine to break from loop -/// -public class BreakException : Exception -{ - /// - /// Creates a new break exception - /// - public BreakException() : base("Break from loop") - { - } -} - -/// -/// Script globals for execution - provides access to built-in functions and global state -/// -public class ScriptGlobals -{ - /// - /// Reference to scope manager for variable access - /// - public BlockScopeManager ScopeManager { get; } - - /// - /// Input parameters passed to the script - /// - public Dictionary? Parameters { get; } - - /// - /// Creates script globals - /// - public ScriptGlobals(BlockScopeManager scopeManager, Dictionary? parameters = null) - { - ScopeManager = scopeManager; - Parameters = parameters; - } - - /// - /// Branch to another block - /// - public void Branch(bool condition, string trueBlock, string falseBlock) - { - BuiltInFunctions.Branch(condition, trueBlock, falseBlock); - } - - /// - /// Loop while condition is true - /// - public void Loop(bool condition, string loopBlock) - { - BuiltInFunctions.Loop(condition, loopBlock); - } - - /// - /// Print a value - /// - public void Print(object? value) - { - BuiltInFunctions.Print(value); - } - - /// - /// Pause execution - /// - public void Pause(int milliseconds) - { - BuiltInFunctions.Pause(milliseconds); - } -} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/KcsFileService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/KcsFileService.cs index a6f9ac4e..48bd1b97 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/KcsFileService.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/KcsFileService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.IO; using System.Text.Json; using System.Threading.Tasks; @@ -148,12 +148,12 @@ node is InterpolatedStringTextSyntax || // 字符串插值文本部分 return; } - // 4. 允许:return 语句 - if (node is ReturnStatementSyntax) - { - base.Visit(node); - return; - } + // 4. 允许:return 语句 // 不再允许,因为主程序的return语句没有存在的意义 + // if (node is ReturnStatementSyntax) + // { + // base.Visit(node); + // return; + // } // 5. 允许:类型转换 if (node is CastExpressionSyntax) diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs index 8587775b..27f731db 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs @@ -803,6 +803,48 @@ public async Task ExecuteBlockScriptAsync( return await BlockScriptExecutor.ExecuteAsync(parseResult.Script, parameters, cancellationToken); } + /// + /// 从块脚本源代码执行(带辅助函数) + /// + /// 块脚本源代码 + /// 辅助函数列表 + /// 取消令牌 + /// 执行结果 + public async Task ExecuteBlockScriptAsync( + string sourceCode, + List helperFunctions, + CancellationToken cancellationToken = default) + { + // 1. Parse the block script (using original source with attributes) + var parseResult = BlockScriptParser.Parse(sourceCode); + + if (!parseResult.IsSuccess || parseResult.Script == null) + { + return new BlockScriptExecutionResult + { + IsSuccess = false, + ErrorMessage = parseResult.ErrorMessage ?? "Failed to parse block script" + }; + } + + // 2. Validate + var validationResult = BlockScriptExecutor.Validate(parseResult.Script); + if (!validationResult.IsValid) + { + return new BlockScriptExecutionResult + { + IsSuccess = false, + ErrorMessage = string.Join("; ", validationResult.Errors) + }; + } + + // Store helper functions for execution (these will be injected into each evaluation) + parseResult.Script.HelperFunctions = helperFunctions ?? new List(); + + // 3. Execute + return await BlockScriptExecutor.ExecuteAsync(parseResult.Script, null, cancellationToken); + } + #endregion #endregion @@ -924,102 +966,3 @@ public class WorkflowCase : IWorkflowCase public bool IsRunning { get; set; } public string? ScriptPath { get; set; } } - -/// -/// Plugin service provider implementation for workflow integration -/// -public class PluginServiceProvider : IPluginServiceProvider -{ - private readonly List _runningPlugins = new(); - private readonly object? _pluginsServer; - - /// - /// Constructor - /// - /// Plugins server instance (can be null) - public PluginServiceProvider(object? pluginsServer) - { - _pluginsServer = pluginsServer; - } - - /// - /// Generates a plugin ID from plugin info - /// - private Guid GeneratePluginId(PluginInfo pluginInfo) - { - // Generate deterministic GUID from: PublisherName_AuthorName_Name_Version - var input = $"{pluginInfo.PublisherName}_{pluginInfo.AuthorName}_{pluginInfo.Name}_{pluginInfo.Version}"; - - // Use MD5 hash to create a deterministic GUID - using var md5 = System.Security.Cryptography.MD5.Create(); - var hash = md5.ComputeHash(System.Text.Encoding.UTF8.GetBytes(input)); - - // Convert first 16 bytes to GUID - return new Guid(hash.Take(16).ToArray()); - } - - /// - /// Gets running plugins - /// - public IEnumerable GetRunningPlugins() - { - return _runningPlugins.ToList(); - } - - /// - /// Finds a plugin by name - /// - public PluginInfo? FindPlugin(string pluginName) - { - return _runningPlugins.FirstOrDefault(p => p.Name == pluginName); - } - - /// - /// Finds a connector for a plugin - /// - public object? FindConnector(PluginInfo pluginInfo) - { - // TODO: Implement connector lookup using plugins server - return null; - } - - /// - /// Sends a request asynchronously - /// - public CTask SendRequestAsync(object connector, object request) - { - // TODO: Implement request sending - return CTask.CompletedTask; - } - - /// - /// Subscribes to plugin responses - /// - public void SubscribeToResponses(Action responseHandler) - { - // TODO: Implement response subscription - } - - /// - /// Adds a running plugin - /// - public void AddRunningPlugin(PluginInfo pluginInfo) - { - if (!_runningPlugins.Any(p => GeneratePluginId(p) == GeneratePluginId(pluginInfo))) - { - _runningPlugins.Add(pluginInfo); - } - } - - /// - /// Removes a running plugin - /// - public void RemoveRunningPlugin(Guid pluginId) - { - var plugin = _runningPlugins.FirstOrDefault(p => GeneratePluginId(p) == pluginId); - if (plugin != null) - { - _runningPlugins.Remove(plugin); - } - } -} diff --git a/KitX Clients/KitX Dashboard b/KitX Clients/KitX Dashboard index badd54ff..a141fee7 160000 --- a/KitX Clients/KitX Dashboard +++ b/KitX Clients/KitX Dashboard @@ -1 +1 @@ -Subproject commit badd54ff3c5f82a3b6b3c57be6da5c93f64d679e +Subproject commit a141fee7b9e0b54429887ab944e5520d4ab5acda diff --git a/KitX Standard b/KitX Standard index eba7ee42..314b1841 160000 --- a/KitX Standard +++ b/KitX Standard @@ -1 +1 @@ -Subproject commit eba7ee42dae17b00ca35490dbf69de9752dceea0 +Subproject commit 314b184152ba065c18da81985a37b738a5eb06eb From b41014430afbf87c91262f55d14e6b5de272853a Mon Sep 17 00:00:00 2001 From: StarInk Date: Mon, 30 Mar 2026 07:09:27 +0200 Subject: [PATCH 025/110] =?UTF-8?q?=F0=9F=92=BE=20Feat(Workflow):=20Implem?= =?UTF-8?q?ent=20Blueprint=20service=20and=20conversion=20logic-part=20of?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 21 + .../KitX.Core.BluePrint.Test.csproj | 20 + .../KitX.Core.BluePrint.Test/Program.cs | 319 ++++ .../DI/CoreServiceCollectionExtensions.cs | 13 + .../KitX Core/KitX.Core/KitX.Core.csproj | 5 +- .../BlockScripting/BlockScriptParser.cs | 5 +- .../BlockScriptToBlueprintConverter.cs | 1639 +++++++++++++++++ .../Workflow/Blueprint/BlueprintService.cs | 120 ++ .../BlueprintToBlockScriptConverter.cs | 578 ++++++ .../Blueprint/NodeTemplateProvider.cs | 208 +++ KitX Clients/KitX Dashboard | 2 +- KitX Standard | 2 +- KitX.sln | 23 + 13 files changed, 2951 insertions(+), 4 deletions(-) create mode 100644 KitX Clients/KitX Core/KitX.Core.BluePrint.Test/KitX.Core.BluePrint.Test.csproj create mode 100644 KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlockScriptToBlueprintConverter.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintService.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintToBlockScriptConverter.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeTemplateProvider.cs diff --git a/.gitignore b/.gitignore index dc32f326..8397999b 100644 --- a/.gitignore +++ b/.gitignore @@ -368,3 +368,24 @@ MigrationBackup/ # Package folder Package/* + +# ── GSD baseline (auto-generated) ── +.gsd +.DS_Store +Thumbs.db +*.swp +*.swo +*.code-workspace +.env +.env.* +!.env.example +.next/ +dist/ +build/ +.venv/ +venv/ +target/ +vendor/ +coverage/ +.cache/ +tmp/ diff --git a/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/KitX.Core.BluePrint.Test.csproj b/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/KitX.Core.BluePrint.Test.csproj new file mode 100644 index 00000000..4093a825 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/KitX.Core.BluePrint.Test.csproj @@ -0,0 +1,20 @@ + + + + Exe + net10.0 + enable + enable + KitX.Core.BluePrint.Test + + + + + + + + + + + + diff --git a/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs b/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs new file mode 100644 index 00000000..d051bd97 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs @@ -0,0 +1,319 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.DependencyInjection; +using KitX.Core.DI; +using KitX.Core.Contract.Workflow; +using KitX.Core.Workflow.Blueprint; + +namespace KitX.Core.BluePrint.Test; + +public class Program +{ + public static void Main(string[] args) + { + Console.WriteLine("╔════════════════════════════════════════════════════════╗"); + Console.WriteLine("║ KitX Core - BlurPrint (Blueprint) Test ║"); + Console.WriteLine("╚════════════════════════════════════════════════════════╝\n"); + + // ============================================================ + // Step 1: Initialize DI Container + // ============================================================ + var services = new ServiceCollection(); + services.AddCoreServices(); + var serviceProvider = services.BuildServiceProvider(); + + // Get IBlockScriptParser from DI + var parser = serviceProvider.GetRequiredService(); + Console.WriteLine("✓ DI Container initialized"); + Console.WriteLine($"✓ IBlockScriptParser resolved: {parser.GetType().Name}\n"); + + // ============================================================ + // Step 2: Create BlockScriptToBlueprintConverter + // ============================================================ + // Note: IBlockScriptToBlueprintConverter is NOT in DI, manually instantiate + var converter = new BlockScriptToBlueprintConverter(parser); + + // ============================================================ + // Step 3: Sample BlockScript Source Code + // ============================================================ + var sourceCode = GetSampleBlockScript(); + + // ============================================================ + // Step 4: Parse BlockScript + // ============================================================ + Console.WriteLine("┌─────────────────────────────────────────────────────────┐"); + Console.WriteLine("│ Parsing BlockScript │"); + Console.WriteLine("└─────────────────────────────────────────────────────────┘\n"); + + var parseResult = parser.Parse(sourceCode); + + if (!parseResult.IsSuccess || parseResult.Script == null) + { + Console.WriteLine($"✗ Parse failed: {parseResult.ErrorMessage}"); + Console.WriteLine($" Error at line: {parseResult.ErrorLine}"); + return; + } + + Console.WriteLine("✓ BlockScript parsed successfully\n"); + + var script = parseResult.Script; + + // Display parsed structure + Console.WriteLine(" Parsed Structure:"); + Console.WriteLine($" - ConstBlock: {(script.ConstBlock != null ? script.ConstBlock.Name : "null")}"); + Console.WriteLine($" - PubVarBlock: {(script.PubVarBlock != null ? script.PubVarBlock.Name : "null")}"); + Console.WriteLine($" - MainBlock: {(script.MainBlock != null ? script.MainBlock.Name : "null")}"); + Console.WriteLine($" - NamedBlocks: {script.NamedBlocks.Count}"); + Console.WriteLine($" - LoopBlocks: {script.LoopBlocks.Count}"); + Console.WriteLine($" - HelperFunctions: {script.HelperFunctions.Count}\n"); + + // ============================================================ + // Step 5: Convert to Blueprint + // ============================================================ + Console.WriteLine("┌─────────────────────────────────────────────────────────┐"); + Console.WriteLine("│ Converting to Blueprint │"); + Console.WriteLine("└─────────────────────────────────────────────────────────┘\n"); + + Contract.Workflow.Blueprint blueprint; + try + { + blueprint = converter.Convert(script); + Console.WriteLine("✓ Blueprint created successfully\n"); + } + catch (Exception ex) + { + Console.WriteLine($"✗ Conversion failed: {ex.Message}"); + Console.WriteLine($" Stack trace: {ex.StackTrace}"); + return; + } + + // ============================================================ + // Step 6: Display Blueprint Summary + // ============================================================ + Console.WriteLine("┌─────────────────────────────────────────────────────────┐"); + Console.WriteLine("│ Blueprint Summary │"); + Console.WriteLine("└─────────────────────────────────────────────────────────┘\n"); + + Console.WriteLine($" Name: {blueprint.Name}"); + Console.WriteLine($" ID: {blueprint.Id}"); + Console.WriteLine($" Nodes: {blueprint.Nodes.Count}"); + Console.WriteLine($" Connections: {blueprint.Connections.Count}"); + Console.WriteLine($" HelperFunctions: {blueprint.HelperFunctions.Count}"); + Console.WriteLine($" PubVarNames: {blueprint.PubVarNames.Count}"); + Console.WriteLine($" ConstValues: {blueprint.ConstValues.Count}\n"); + + // ============================================================ + // Step 7: Display HelperFunctions + // ============================================================ + if (blueprint.HelperFunctions.Count > 0) + { + Console.WriteLine("┌─────────────────────────────────────────────────────────┐"); + Console.WriteLine("│ HelperFunctions │"); + Console.WriteLine("└─────────────────────────────────────────────────────────┘\n"); + + foreach (var helper in blueprint.HelperFunctions) + { + Console.WriteLine($" • {helper.Name}"); + if (helper.Parameters.Count > 0) + { + var @params = string.Join(", ", helper.Parameters.Select(p => $"{p.Type} {p.Name}")); + Console.WriteLine($" Parameters: {@params}"); + } + } + Console.WriteLine(); + } + + // ============================================================ + // Step 8: Display PubVarNames + // ============================================================ + if (blueprint.PubVarNames.Count > 0) + { + Console.WriteLine("┌─────────────────────────────────────────────────────────┐"); + Console.WriteLine("│ PubVarNames (Public Variables) │"); + Console.WriteLine("└─────────────────────────────────────────────────────────┘\n"); + + foreach (var pubVar in blueprint.PubVarNames) + { + Console.WriteLine($" • {pubVar}"); + } + Console.WriteLine(); + } + + // ============================================================ + // Step 9: Display ConstValues + // ============================================================ + if (blueprint.ConstValues.Count > 0) + { + Console.WriteLine("┌─────────────────────────────────────────────────────────┐"); + Console.WriteLine("│ ConstValues (Variable Constants) │"); + Console.WriteLine("└─────────────────────────────────────────────────────────┘\n"); + + foreach (var vc in blueprint.ConstValues) + { + Console.WriteLine($" • {vc.Name}: {vc.Type} = {vc.DefaultValue}"); + } + Console.WriteLine(); + } + + // ============================================================ + // Step 10: Display Nodes by Type + // ============================================================ + Console.WriteLine("┌─────────────────────────────────────────────────────────┐"); + Console.WriteLine("│ Nodes (Grouped by Type) │"); + Console.WriteLine("└─────────────────────────────────────────────────────────┘\n"); + + var nodesByType = blueprint.Nodes + .GroupBy(n => n.NodeType) + .OrderBy(g => g.Key.ToString()); + + foreach (var group in nodesByType) + { + Console.WriteLine($" [{group.Key}] ({group.Count()} nodes)"); + foreach (var node in group) + { + var inputPins = string.Join(", ", + node.InputPins.Select(p => $"{p.Name}({p.Direction}, {p.Type})")); + var outputPins = string.Join(", ", + node.OutputPins.Select(p => $"{p.Name}({p.Direction}, {p.Type})")); + + // Type-specific extra info + var extra = node switch + { + ConstNode cn => $" | ConstName={cn.ConstName}, Value={cn.ConstValue}", + CallNode call => $" | Function={call.FunctionName}", + CallHelperNode helper => $" | Helper={helper.HelperFunctionName}", + GetNode gn => $" | VarName={gn.VarName}", + SetNode sn => $" | VarName={sn.VarName}", + BranchNode bn => $" | Type=Branch", + LoopNode ln => $" | Type=Loop", + EntryNode en => $" | Type=Entry", + BreakNode brk => $" | Type=Break", + PrintNode pn => $" | Type=Print", + PauseNode pause => $" | Type=Pause", + _ => "" + }; + + Console.WriteLine($" • {node.Name} ({node.Id})"); + + // 显示输入引脚,包含预设值 + var inputPinsWithDefault = string.Join(", ", + node.InputPins.Select(p => p.DefaultValue != null + ? $"{p.Name}({p.Direction}, {p.Type}) = \"{p.DefaultValue}\"" + : $"{p.Name}({p.Direction}, {p.Type})")); + Console.WriteLine($" Inputs: [{inputPinsWithDefault}]"); + + // 显示输出引脚 + var outputPinsWithDefault = string.Join(", ", + node.OutputPins.Select(p => p.DefaultValue != null + ? $"{p.Name}({p.Direction}, {p.Type}) = \"{p.DefaultValue}\"" + : $"{p.Name}({p.Direction}, {p.Type})")); + Console.WriteLine($" Outputs: [{outputPinsWithDefault}]{extra}"); + } + Console.WriteLine(); + } + + // ============================================================ + // Step 11: Display Connections (Exec vs Data) + // ============================================================ + Console.WriteLine("┌─────────────────────────────────────────────────────────┐"); + Console.WriteLine("│ Connections │"); + Console.WriteLine("└─────────────────────────────────────────────────────────┘\n"); + + var execConnections = new List(); + var dataConnections = new List(); + + foreach (var conn in blueprint.Connections) + { + var sourceNode = blueprint.GetNodeById(conn.SourceNodeId); + var targetNode = blueprint.GetNodeById(conn.TargetNodeId); + var sourcePin = sourceNode?.OutputPins.FirstOrDefault(p => p.Id == conn.SourcePinId); + var targetPin = targetNode?.InputPins.FirstOrDefault(p => p.Id == conn.TargetPinId); + + if (sourcePin?.Type == PinType.Execution || targetPin?.Type == PinType.Execution) + execConnections.Add(conn); + else + dataConnections.Add(conn); + } + + Console.WriteLine($" Exec Connections: {execConnections.Count}"); + foreach (var conn in execConnections) + { + var src = blueprint.GetNodeById(conn.SourceNodeId); + var tgt = blueprint.GetNodeById(conn.TargetNodeId); + var sp = src?.OutputPins.FirstOrDefault(p => p.Id == conn.SourcePinId); + var tp = tgt?.InputPins.FirstOrDefault(p => p.Id == conn.TargetPinId); + Console.WriteLine($" • {src?.Name}.{sp?.Name} -> {tgt?.Name}.{tp?.Name}"); + } + + Console.WriteLine($"\n Data Connections: {dataConnections.Count}"); + foreach (var conn in dataConnections) + { + var src = blueprint.GetNodeById(conn.SourceNodeId); + var tgt = blueprint.GetNodeById(conn.TargetNodeId); + var sp = src?.OutputPins.FirstOrDefault(p => p.Id == conn.SourcePinId); + var tp = tgt?.InputPins.FirstOrDefault(p => p.Id == conn.TargetPinId); + var pubvar = conn.PubVarName != null ? $" (PubVar: {conn.PubVarName})" : ""; + Console.WriteLine($" • {src?.Name}.{sp?.Name} -> {tgt?.Name}.{tp?.Name}{pubvar}"); + } + + Console.WriteLine("\n═════════════════════════════════════════════════════════"); + Console.WriteLine(" Test Complete"); + Console.WriteLine("═════════════════════════════════════════════════════════\n"); + } + + private static string GetSampleBlockScript() + { + var sb = new System.Text.StringBuilder(); + sb.AppendLine("#ConstBlock"); + sb.AppendLine("int guessNum = 5;"); + sb.AppendLine("int loopMax = 3;"); + sb.AppendLine("int targetNum = 7;"); + sb.AppendLine("int currentLoop;"); + sb.AppendLine(); + sb.AppendLine("#PubVarBlock"); + sb.AppendLine("var vaaa0001;"); + sb.AppendLine("var vaaa0002;"); + sb.AppendLine(); + sb.AppendLine("#MainBlock"); + sb.AppendLine("Print(\"开始执行工作流\");"); + sb.AppendLine("Set(currentLoop, 0);"); + sb.AppendLine("vaaa0001 = HelperFuncCompare(\"BLE\", Get(currentLoop), loopMax);"); + sb.AppendLine("NextBlock = Loop(vaaa0001, \"LoopBody\", \"EndLogic\");"); + sb.AppendLine(); + sb.AppendLine("#Block LoopBody"); + sb.AppendLine("vaaa0002 = Get(currentLoop);"); + sb.AppendLine("Print(vaaa0002);"); + sb.AppendLine("Set(currentLoop, HelperFuncAdd(Get(currentLoop), 1));"); + sb.AppendLine("NextBlock = Branch("); + sb.AppendLine(" HelperFuncCompare(\"BEQ\", guessNum, targetNum),"); + sb.AppendLine(" \"SuccessLogic\","); + sb.AppendLine(" \"CheckLogic\""); + sb.AppendLine(");"); + sb.AppendLine(); + sb.AppendLine("#Block CheckLogic"); + sb.AppendLine("NextBlock = Branch("); + sb.AppendLine(" HelperFuncCompare(\"BLT\", guessNum, targetNum),"); + sb.AppendLine(" \"LessThanLogic\","); + sb.AppendLine(" \"GreaterThanLogic\""); + sb.AppendLine(");"); + sb.AppendLine(); + sb.AppendLine("#Block LessThanLogic"); + sb.AppendLine("Print(\"猜小了\");"); + sb.AppendLine("vaaa0001 = HelperFuncCompare(\"BLE\", Get(currentLoop), loopMax);"); + sb.AppendLine("NextBlock = LoopBodyEnd(\"MainBlock\");"); + sb.AppendLine(); + sb.AppendLine("#Block GreaterThanLogic"); + sb.AppendLine("Print(\"猜大了\");"); + sb.AppendLine("vaaa0001 = HelperFuncCompare(\"BLE\", Get(currentLoop), loopMax);"); + sb.AppendLine("NextBlock = LoopBodyEnd(\"MainBlock\");"); + sb.AppendLine(); + sb.AppendLine("#Block SuccessLogic"); + sb.AppendLine("Print(\"猜对啦!\");"); + sb.AppendLine(); + sb.AppendLine("#Block EndLogic"); + sb.AppendLine("Print(\"示例工作流结束\");"); + + return sb.ToString(); + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs b/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs index 49b83637..7bd257b7 100644 --- a/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs +++ b/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs @@ -23,6 +23,7 @@ using KitX.Core.Tasks; using KitX.Core.Workflow; using KitX.Core.Workflow.BlockScripting; +using KitX.Core.Workflow.Blueprint; using KitX.Core.Event; using Serilog; @@ -196,6 +197,18 @@ public static IServiceCollection AddCoreServices(this IServiceCollection service return service; }); + // Blueprint Services + Log.Information("Registering IBlueprintService..."); + services.AddSingleton(); + + // Node Template Provider + Log.Information("Registering INodeTemplateProvider..."); + services.AddSingleton(provider => + { + var service = new NodeTemplateProvider(); + return service; + }); + Log.Information("AddCoreServices completed."); return services; } diff --git a/KitX Clients/KitX Core/KitX.Core/KitX.Core.csproj b/KitX Clients/KitX Core/KitX.Core/KitX.Core.csproj index 5dc6010f..d880917d 100644 --- a/KitX Clients/KitX Core/KitX.Core/KitX.Core.csproj +++ b/KitX Clients/KitX Core/KitX.Core/KitX.Core.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -18,6 +18,9 @@ <_Parameter1>KitX.Core.DI.Tests + + <_Parameter1>KitX.Core.BluePrint.Test + diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptParser.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptParser.cs index 43c7ba74..37d97718 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptParser.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptParser.cs @@ -779,6 +779,8 @@ private void CreateLoopBlocksForBlock(BlockDefinition block, BlockScript script) loopBlock.NextBlockName = firstLoop.FalseBlockName; // Add a copy of the Loop statement to the LoopBlock + // IMPORTANT: Set LoopBodyEndReturnTo to the parent block name so that + // LoopBodyEnd statements can find this Loop node when they return var loopBlockStatement = new FlowControlStatement { LineNumber = firstLoop.LineNumber, @@ -786,7 +788,8 @@ private void CreateLoopBlocksForBlock(BlockDefinition block, BlockScript script) ControlType = FlowControlType.Loop, ConditionExpression = firstLoop.ConditionExpression, TrueBlockName = firstLoop.TrueBlockName, - FalseBlockName = firstLoop.FalseBlockName + FalseBlockName = firstLoop.FalseBlockName, + LoopBodyEndReturnTo = block.Name // Parent block name - for LoopBodyEnd lookup }; loopBlock.Statements.Add(loopBlockStatement); diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlockScriptToBlueprintConverter.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlockScriptToBlueprintConverter.cs new file mode 100644 index 00000000..1e425407 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlockScriptToBlueprintConverter.cs @@ -0,0 +1,1639 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using KitX.Core.Contract.Workflow; +using Serilog; + +namespace KitX.Core.Workflow.Blueprint; + +/// +/// Converts BlockScript to Blueprint +/// +public class BlockScriptToBlueprintConverter : IBlockScriptToBlueprintConverter +{ + private readonly IBlockScriptParser _parser; + + public BlockScriptToBlueprintConverter(IBlockScriptParser parser) + { + _parser = parser; + } + + public Contract.Workflow.Blueprint Convert(string sourceCode, List? helperFunctions = null) + { + var result = _parser.Parse(sourceCode); + if (!result.IsSuccess || result.Script == null) + { + throw new InvalidOperationException($"Failed to parse BlockScript: {result.ErrorMessage}"); + } + return Convert(result.Script); + } + + public Contract.Workflow.Blueprint Convert(BlockScript script) + { + var blueprint = new Contract.Workflow.Blueprint + { + Name = "Imported from BlockScript", + HelperFunctions = script.HelperFunctions, + PubVarNames = new List() + }; + + var context = new ConversionContext + { + Blueprint = blueprint, + Script = script, + NodeMap = new Dictionary(), + NextPubVarIndex = 0 + }; + + // Process ConstBlock + if (script.ConstBlock != null) + { + ProcessConstBlock(script.ConstBlock, context); + } + + // Process PubVarBlock + if (script.PubVarBlock != null) + { + ProcessPubVarBlock(script.PubVarBlock, context); + } + + // Process MainBlock - create Entry node and main execution flow + if (script.MainBlock != null) + { + ProcessMainBlock(script.MainBlock, context); + } + + // Process NamedBlocks (for calls from main flow) + foreach (var kvp in script.NamedBlocks) + { + context.NamedBlockMap[kvp.Key] = kvp.Value; + } + + // Process LoopBlocks + foreach (var kvp in script.LoopBlocks) + { + ProcessLoopBlock(kvp.Value, context); + } + + // Auto-layout nodes + LayoutNodes(blueprint); + + Log.Information("[BlueprintConversion] Complete: {NodeCount} nodes, {ConnectionCount} connections", + blueprint.Nodes.Count, blueprint.Connections.Count); + + return blueprint; + } + + private void ProcessConstBlock(BlockDefinition block, ConversionContext context) + { + foreach (var variable in block.Variables) + { + // ✅ 如果 ConstValue 为空,根据类型设置默认值 + var constValue = variable.DefaultValue?.ToString(); + if (string.IsNullOrEmpty(constValue)) + { + constValue = variable.Type.ToLower() switch + { + "int" or "integer" or "long" or "short" or "byte" => "0", + "float" or "double" or "decimal" => "0", + "bool" or "boolean" => "false", + "string" => "", + "char" => "\0", + _ => "" + }; + } + + var constNode = new ConstNode + { + ConstName = variable.Name, + ConstType = variable.Type, + ConstValue = constValue + }; + context.Blueprint.AddNode(constNode); + context.Blueprint.ConstValues.Add(new VariableConstant + { + Name = variable.Name, + Type = variable.Type, + DefaultValue = variable.DefaultValue + }); + Log.Debug("Created ConstNode: Name={ConstName}, Id={NodeId}", constNode.Name, constNode.Id); + } + } + + private void ProcessPubVarBlock(BlockDefinition block, ConversionContext context) + { + foreach (var variable in block.Variables) + { + context.Blueprint.PubVarNames.Add(variable.Name); + } + } + + private void ProcessMainBlock(BlockDefinition block, ConversionContext context) + { + // Create Entry node + var entryNode = new EntryNode + { + X = 50, + Y = 50 + }; + context.Blueprint.AddNode(entryNode); + context.EntryNode = entryNode; + Log.Debug("Created EntryNode: Name={NodeName}, Id={NodeId}", entryNode.Name, entryNode.Id); + + // Process all statements in main block + var mainFlowNodes = new List(); + var currentY = 150.0; + + Console.WriteLine($"[DEBUG] ProcessMainBlock: Starting foreach, Statements.Count = {block.Statements.Count}"); + for (int i = 0; i < block.Statements.Count; i++) + { + var statement = block.Statements[i]; + var sourcePreview = statement.SourceCode?.Length > 50 + ? statement.SourceCode.Substring(0, 50) + "..." + : statement.SourceCode ?? "(null)"; + Console.WriteLine($"[DEBUG] ProcessMainBlock: Processing statement {i}: {statement.GetType().Name}, SourceCode: {sourcePreview}"); + var node = ProcessStatement(statement, context, 50, currentY); + Console.WriteLine($"[DEBUG] ProcessMainBlock: ProcessStatement returned: {node?.Name} ({node?.Id})"); + if (node != null) + { + Console.WriteLine($"[DEBUG] ProcessMainBlock: Adding node {node.Name} ({node.Id}) to blueprint"); + context.Blueprint.AddNode(node); + mainFlowNodes.Add(node); + currentY += 120; + } + } + Console.WriteLine($"[DEBUG] ProcessMainBlock: Finished foreach, mainFlowNodes.Count = {mainFlowNodes.Count}"); + + // Link main flow with Exec connections + LinkNodesWithExec(mainFlowNodes, context.Blueprint); + + Console.WriteLine($"[DEBUG] ProcessMainBlock: mainFlowNodes count = {mainFlowNodes.Count}"); + for (int i = 0; i < mainFlowNodes.Count; i++) + { + Console.WriteLine($"[DEBUG] ProcessMainBlock: mainFlowNodes[{i}] = {mainFlowNodes[i].Name} ({mainFlowNodes[i].Id})"); + } + + // ✅ 处理 Branch/Loop 节点与子块的连接 + // 注意:Loop 节点存储在 LoopBlocks["MainBlock"] 中,不在 MainBlock.Statements + // 因此需要从 context.LoopNodesByParentBlock 中查找 + var flowNode = mainFlowNodes.LastOrDefault(n => n is BranchNode || n is LoopNode); + FlowControlStatement? flowCtrl = null; + + // 如果 mainFlowNodes 中没有找到 Loop,尝试从 LoopBlocks 处理 + if (flowNode == null) + { + // 检查 LoopBlocks 中是否有 MainBlock 的 Loop + if (context.Script.LoopBlocks.TryGetValue("MainBlock", out var loopBlock)) + { + // 获取 Loop FlowControlStatement + flowCtrl = loopBlock.Statements.FirstOrDefault(s => s is FlowControlStatement) as FlowControlStatement; + if (flowCtrl != null) + { + // ✅ 设置 CurrentParentBlock 以便后续 ProcessLoopBlock 复用此 Loop 节点 + context.CurrentParentBlock = "MainBlock"; + + // 在 LoopBlocks["MainBlock"] 中创建 Loop 节点 + var loopNodeInLoopBlock = ProcessFlowControlStatement(flowCtrl, context, 350, 150); + if (loopNodeInLoopBlock is LoopNode loopFromBlock) + { + flowNode = loopFromBlock; + // ✅ 注册到 LoopNodesByParentBlock 以便后续使用 + context.LoopNodesByParentBlock["MainBlock"] = loopFromBlock; + Console.WriteLine($"[DEBUG] ProcessMainBlock: Created Loop node from LoopBlocks, registering as MainBlock's Loop"); + } + } + } + } + else + { + // Branch/Loop 在 MainBlock.Statements 中 + flowCtrl = block.Statements.LastOrDefault(s => s is FlowControlStatement) as FlowControlStatement; + } + + Console.WriteLine($"[DEBUG] ProcessMainBlock: flowNode = {flowNode?.Name} ({flowNode?.Id})"); + if (flowNode != null && flowCtrl != null) + { + Console.WriteLine($"[DEBUG] ProcessMainBlock: Processing flow node connections"); + if (flowNode is BranchNode branch) + { + ProcessBranchNodeConnectionsInMain(branch, flowCtrl, context); + } + else if (flowNode is LoopNode loop) + { + ProcessLoopNodeConnectionsInMain(loop, flowCtrl, context); + } + } + + // Link Entry to first main flow node + if (mainFlowNodes.Count > 0) + { + var firstNode = mainFlowNodes[0]; + var entryExecPin = entryNode.OutputPins.First(p => p.Name == "Exec"); + var firstExecPin = firstNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); + if (entryExecPin != null && firstExecPin != null) + { + context.Blueprint.AddConnection(new BlueprintConnection + { + SourceNodeId = entryNode.Id, + SourcePinId = entryExecPin.Id, + TargetNodeId = firstNode.Id, + TargetPinId = firstExecPin.Id + }); + } + } + } + + private void ProcessBranchNodeConnectionsInMain(BranchNode branch, FlowControlStatement flowCtrl, ConversionContext context) + { + double x = 350; + double targetY = 300; + + // 处理 True 分支 + if (!string.IsNullOrEmpty(flowCtrl.TrueBlockName)) + { + var trueBlock = context.Script.GetBlockByName(flowCtrl.TrueBlockName); + if (trueBlock != null) + { + var firstTrueNode = ProcessBlockRecursive(trueBlock, context, x, targetY); + if (firstTrueNode != null) + { + var truePin = branch.OutputPins.FirstOrDefault(p => p.Name == "True"); + var firstExecIn = firstTrueNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); + if (truePin != null && firstExecIn != null) + { + Console.WriteLine($"[DEBUG] ProcessBranchNodeConnectionsInMain: Creating {branch.Name}.True -> {firstTrueNode.Name}.Exec"); + context.Blueprint.AddConnection(new BlueprintConnection + { + SourceNodeId = branch.Id, + SourcePinId = truePin.Id, + TargetNodeId = firstTrueNode.Id, + TargetPinId = firstExecIn.Id + }); + } + } + } + } + + // 处理 False 分支 + if (!string.IsNullOrEmpty(flowCtrl.FalseBlockName)) + { + var falseBlock = context.Script.GetBlockByName(flowCtrl.FalseBlockName); + if (falseBlock != null) + { + // ✅ 如果 falseBlock 已经访问过,跳过(避免重复创建连接) + if (context.VisitedBlocks.Contains(falseBlock.Name)) + { + Console.WriteLine($"[DEBUG] ProcessBranchNodeConnectionsInMain: falseBlock {falseBlock.Name} already visited, skipping"); + } + else + { + var firstFalseNode = ProcessBlockRecursive(falseBlock, context, x, targetY + 200); + if (firstFalseNode != null && firstFalseNode.Id != branch.Id) + { + var falsePin = branch.OutputPins.FirstOrDefault(p => p.Name == "False"); + var firstExecIn = firstFalseNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); + if (falsePin != null && firstExecIn != null) + { + Console.WriteLine($"[DEBUG] ProcessBranchNodeConnectionsInMain: Creating {branch.Name}.False -> {firstFalseNode.Name}.Exec"); + context.Blueprint.AddConnection(new BlueprintConnection + { + SourceNodeId = branch.Id, + SourcePinId = falsePin.Id, + TargetNodeId = firstFalseNode.Id, + TargetPinId = firstExecIn.Id + }); + } + } + } + } + } + } + + private void ProcessLoopNodeConnectionsInMain(LoopNode loop, FlowControlStatement flowCtrl, ConversionContext context) + { + // ✅ 使用已创建的 loop 节点,不重新创建 + double x = 350; + double targetY = 300; + + // ✅ 处理 LoopBody + if (!string.IsNullOrEmpty(flowCtrl.TrueBlockName)) + { + var loopBodyBlock = context.Script.GetBlockByName(flowCtrl.TrueBlockName); + if (loopBodyBlock != null) + { + // ✅ 同时注册 TrueBlockName 和 ParentBlockName,以便 LoopBodyEnd 查找 + context.LoopNodesByParentBlock[flowCtrl.TrueBlockName] = loop; + if (!string.IsNullOrEmpty(flowCtrl.LoopBodyEndReturnTo)) + { + context.LoopNodesByParentBlock[flowCtrl.LoopBodyEndReturnTo] = loop; + } + + var firstLoopNode = ProcessBlockRecursive(loopBodyBlock, context, x, targetY); + if (firstLoopNode != null) + { + var loopBodyPin = loop.OutputPins.FirstOrDefault(p => p.Name == "LoopBody"); + var firstExecIn = firstLoopNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); + if (loopBodyPin != null && firstExecIn != null) + { + Console.WriteLine($"[DEBUG] ProcessLoopNodeConnectionsInMain: Creating {loop.Name}.LoopBody -> {firstLoopNode.Name}.Exec"); + context.Blueprint.AddConnection(new BlueprintConnection + { + SourceNodeId = loop.Id, + SourcePinId = loopBodyPin.Id, + TargetNodeId = firstLoopNode.Id, + TargetPinId = firstExecIn.Id + }); + } + } + } + } + + // 处理 LoopEnd + if (!string.IsNullOrEmpty(flowCtrl.FalseBlockName)) + { + var afterLoopBlock = context.Script.GetBlockByName(flowCtrl.FalseBlockName); + if (afterLoopBlock != null) + { + var firstAfterLoopNode = ProcessBlockRecursive(afterLoopBlock, context, x, targetY + 200); + if (firstAfterLoopNode != null) + { + var loopEndPin = loop.OutputPins.FirstOrDefault(p => p.Name == "LoopEnd"); + var firstExecIn = firstAfterLoopNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); + if (loopEndPin != null && firstExecIn != null) + { + Console.WriteLine($"[DEBUG] ProcessLoopNodeConnectionsInMain: Creating {loop.Name}.LoopEnd -> {firstAfterLoopNode.Name}.Exec"); + context.Blueprint.AddConnection(new BlueprintConnection + { + SourceNodeId = loop.Id, + SourcePinId = loopEndPin.Id, + TargetNodeId = firstAfterLoopNode.Id, + TargetPinId = firstExecIn.Id + }); + } + } + } + } + + // 处理 LoopBodyEndReturnTo + if (!string.IsNullOrEmpty(flowCtrl.LoopBodyEndReturnTo)) + { + context.LoopNodesByParentBlock[flowCtrl.LoopBodyEndReturnTo] = loop; + } + } + + private BlueprintNode? ProcessStatement(BlockStatement statement, ConversionContext context, double x, double y) + { + Console.WriteLine($"[DEBUG] ProcessStatement: {statement.GetType().Name}"); + switch (statement) + { + case FlowControlStatement flowCtrl: + Console.WriteLine($"[DEBUG] ProcessStatement: FlowControlStatement ControlType={flowCtrl.ControlType}, TrueBlock={flowCtrl.TrueBlockName}, FalseBlock={flowCtrl.FalseBlockName}, SourceCode={flowCtrl.SourceCode}"); + // ✅ NextBlock = Branch/Loop(...) 需要创建节点!不能跳过 + // 因为 Branch/Loop 节点有输出引脚需要连接到子块 + if ((flowCtrl.ControlType == FlowControlType.Branch || flowCtrl.ControlType == FlowControlType.Loop) && + !string.IsNullOrEmpty(flowCtrl.TrueBlockName) && + !string.IsNullOrEmpty(flowCtrl.FalseBlockName)) + { + // ✅ 对于 Loop 节点,检查是否已存在(避免重复创建) + if (flowCtrl.ControlType == FlowControlType.Loop && + context.CurrentParentBlock != null && + context.LoopNodesByParentBlock.TryGetValue(context.CurrentParentBlock, out var existingLoop)) + { + Console.WriteLine($"[DEBUG] ProcessStatement: Reusing existing Loop node for {context.CurrentParentBlock}"); + context.LastProcessedNode = existingLoop; + return existingLoop; + } + + Console.WriteLine($"[DEBUG] ProcessStatement: Creating {flowCtrl.ControlType} node for NextBlock = {flowCtrl.ControlType}(...)"); + var flowNode = ProcessFlowControlStatement(flowCtrl, context, x, y); + if (flowNode != null) + { + context.LastProcessedNode = flowNode; + } + return flowNode; + } + if (flowCtrl.ControlType == FlowControlType.LoopBodyEnd) + { + HandleLoopBodyEnd(flowCtrl, context); + return null; + } + var otherFlowNode = ProcessFlowControlStatement(flowCtrl, context, x, y); + if (otherFlowNode != null) + { + context.LastProcessedNode = otherFlowNode; + } + return otherFlowNode; + + case ExpressionStatement expr: + if (expr.Expression.StartsWith("NextBlock = ")) + return null; + var actionNode = CreateActionNodeFromExpression(expr.Expression, context); + if (actionNode != null) + { + context.LastProcessedNode = actionNode; + } + return actionNode; + + default: + return null; + } + } + + private void HandleLoopBodyEnd(FlowControlStatement flowCtrl, ConversionContext context) + { + if (!string.IsNullOrEmpty(flowCtrl.LoopBodyEndReturnTo)) + { + // ✅ 查找对应的Loop节点 + if (context.LoopNodesByParentBlock.TryGetValue(flowCtrl.LoopBodyEndReturnTo, out var loopNode)) + { + // ✅ 获取当前块中最后一个已处理的节点 + var lastNode = context.LastProcessedNode; + + if (lastNode != null) + { + // ✅ 建立从最后节点到Loop节点Condition输入的执行流回连 + var lastExecOut = lastNode.OutputPins.FirstOrDefault(p => p.Name == "Exec"); + var loopConditionIn = loopNode.InputPins.FirstOrDefault(p => p.Name == "Condition"); + + if (lastExecOut != null && loopConditionIn != null) + { + context.Blueprint.AddConnection(new BlueprintConnection + { + SourceNodeId = lastNode.Id, + SourcePinId = lastExecOut.Id, + TargetNodeId = loopNode.Id, + TargetPinId = loopConditionIn.Id, + PubVarName = null + }); + Log.Debug("Created LoopBodyEnd Exec return: {LastNode} -> Loop.Condition"); + } + } + + // ✅ 同时更新LoopNodesByParentBlock以便下次引用 + context.LoopNodesByParentBlock[flowCtrl.LoopBodyEndReturnTo] = loopNode; + } + } + } + + private BlueprintNode? ProcessFlowControlStatement(FlowControlStatement flowCtrl, ConversionContext context, double x, double y) + { + var node = CreateControlFlowNode(flowCtrl, context); + if (node == null) return null; + + node.X = x; + node.Y = y; + + // ✅ 不在这里添加到 blueprint,由调用者(ProcessStatement)负责添加 + // 这样可以避免重复添加 + + if (!string.IsNullOrEmpty(flowCtrl.ConditionExpression)) + { + CreateDataConnectionsForExpression(flowCtrl.ConditionExpression, node, context); + } + + double targetY = y + 150; + + switch (flowCtrl.ControlType) + { + case FlowControlType.Branch: + // ✅ 不在这里处理子块连接,由 ProcessBlockRecursive 通过 NextBlock 属性统一处理 + // 这样可以避免重复处理和 visited 问题 + Console.WriteLine($"[DEBUG] ProcessFlowControlStatement: Branch node created, TrueBlock={flowCtrl.TrueBlockName}, FalseBlock={flowCtrl.FalseBlockName}"); + break; + + case FlowControlType.Loop: + // ✅ 不在这里处理子块连接,由 ProcessBlockRecursive 通过 NextBlock 属性统一处理 + Console.WriteLine($"[DEBUG] ProcessFlowControlStatement: Loop node created, LoopBody={flowCtrl.TrueBlockName}, LoopEnd={flowCtrl.FalseBlockName}"); + break; + } + + return node; + } + + private BlueprintNode? ProcessBlockRecursive(BlockDefinition block, ConversionContext context, double x, double y) + { + Console.WriteLine($"[DEBUG] ProcessBlockRecursive: block={block.Name}, type={block.Type}, statements={block.Statements.Count}"); + + // 如果块已经访问过,返回第一个节点用于建立连接(而不是null) + if (context.VisitedBlocks.Contains(block.Name)) + { + var cachedFirstNode = context.BlockFirstNodes.GetValueOrDefault(block.Name); + Console.WriteLine($"[DEBUG] ProcessBlockRecursive: block {block.Name} ALREADY VISITED, returning {(cachedFirstNode != null ? cachedFirstNode.Name : "null")}"); + return cachedFirstNode; + } + context.VisitedBlocks.Add(block.Name); + + var nodes = new List(); + var currentY = y; + + Console.WriteLine($"[DEBUG] ProcessBlockRecursive: Processing {block.Statements.Count} statements for block {block.Name}"); + // 首先处理所有可执行语句节点 + foreach (var statement in block.Statements) + { + Console.WriteLine($"[DEBUG] ProcessBlockRecursive: Processing statement: {statement.GetType().Name}"); + var node = ProcessStatement(statement, context, x, currentY); + Console.WriteLine($"[DEBUG] ProcessBlockRecursive: ProcessStatement returned: {node?.Name} ({node?.Id})"); + if (node != null) + { + // ✅ 检查节点是否已经存在于 Blueprint 中,避免重复添加 + if (!context.Blueprint.Nodes.Any(n => n.Id == node.Id)) + { + Console.WriteLine($"[DEBUG] ProcessBlockRecursive: Adding node {node.Name} ({node.Id}) to blueprint"); + context.Blueprint.AddNode(node); + } + else + { + Console.WriteLine($"[DEBUG] ProcessBlockRecursive: Node {node.Name} ({node.Id}) already in blueprint, skipping"); + } + nodes.Add(node); + currentY += 120; + } + } + Console.WriteLine($"[DEBUG] ProcessBlockRecursive: After processing statements, nodes.Count = {nodes.Count}"); + + // 如果块只有路由语句(nodes为空),仍然需要处理路由以建立连接 + if (nodes.Count == 0 && block.Statements.Count > 0) + { + var firstStatement = block.Statements[0]; + if (firstStatement is FlowControlStatement flowCtrl && + (flowCtrl.ControlType == FlowControlType.Branch || flowCtrl.ControlType == FlowControlType.Loop)) + { + // 这是一个纯路由块,需要处理路由语句以建立连接 + Console.WriteLine($"[DEBUG] ProcessBlockRecursive: Processing routing-only block {block.Name}"); + var routingNode = ProcessFlowControlStatement(flowCtrl, context, x, y); + if (routingNode != null) + { + context.Blueprint.AddNode(routingNode); + nodes.Add(routingNode); + Console.WriteLine($"[DEBUG] ProcessBlockRecursive: Added routing node {routingNode.Name} ({routingNode.Id}) to nodes list"); + } + } + } + + LinkNodesWithExec(nodes, context.Blueprint); + + // ✅ 标记为已访问 + context.VisitedBlocks.Add(block.Name); + + // ✅ 处理 Branch/Loop 节点与子块的连接 + var flowNode = nodes.LastOrDefault(n => n is BranchNode || n is LoopNode); + if (flowNode != null) + { + Console.WriteLine($"[DEBUG] ProcessBlockRecursive: Processing flow node connections for {block.Name}"); + if (flowNode is BranchNode branch) + { + ProcessBranchNodeConnections(branch, block, context, x, y); + } + else if (flowNode is LoopNode loop) + { + ProcessLoopNodeConnections(loop, block, context, x, y); + } + } + + // 处理跨块连接 + if (!string.IsNullOrEmpty(block.NextBlockName) && + block.Type != BlockType.LoopBlock) + { + var nextBlock = context.Script.GetBlockByName(block.NextBlockName); + if (nextBlock != null && !context.VisitedBlocks.Contains(nextBlock.Name)) + { + if (nodes.Count > 0) + { + var lastNode = nodes.Last(); + var nextFirstNode = ProcessBlockRecursive(nextBlock, context, x + 300, y); + if (nextFirstNode != null) + { + var lastExecOut = lastNode.OutputPins.FirstOrDefault(p => p.Name == "Exec"); + var nextExecIn = nextFirstNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); + + // ✅ 验证是有效的 Exec 连接 + if (lastExecOut == null || lastExecOut.Direction != PinDirection.Output) + { + Log.Warning("Cannot create cross-block Exec connection: {Node}.Exec is not a valid output pin", + lastNode.Name); + } + else if (lastNode.Id == nextFirstNode.Id) + { + Log.Warning("Skipping self-loop cross-block Exec connection"); + } + else if (lastExecOut != null && nextExecIn != null) + { + context.Blueprint.AddConnection(new BlueprintConnection + { + SourceNodeId = lastNode.Id, + SourcePinId = lastExecOut.Id, + TargetNodeId = nextFirstNode.Id, + TargetPinId = nextExecIn.Id + }); + } + } + } + } + } + + // 存储块的第一个节点,以便后续已访问块的连接 + var firstNode = nodes.FirstOrDefault(); + if (firstNode != null) + { + context.BlockFirstNodes[block.Name] = firstNode; + } + + return firstNode; + } + + private void ProcessBranchNodeConnections(BranchNode branch, BlockDefinition block, ConversionContext context, double x, double y) + { + var callId = Guid.NewGuid().ToString().Substring(0, 8); + Console.WriteLine($"[DEBUG] ProcessBranchNodeConnections ENTER: callId={callId}, branch={branch.Id.Substring(0,8)}, block={block.Name}"); + // 找到对应的 FlowControlStatement + var flowCtrl = block.Statements.FirstOrDefault(s => s is FlowControlStatement) as FlowControlStatement; + if (flowCtrl == null) return; + + double targetY = y + 150; + + // 处理 True 分支 + if (!string.IsNullOrEmpty(flowCtrl.TrueBlockName)) + { + var trueBlock = context.Script.GetBlockByName(flowCtrl.TrueBlockName); + // ✅ 如果 trueBlock 已经访问过,跳过(避免重复创建连接) + if (trueBlock != null && context.VisitedBlocks.Contains(trueBlock.Name)) + { + Console.WriteLine($"[DEBUG] ProcessBranchNodeConnections[{callId}]: trueBlock {trueBlock.Name} already visited, skipping"); + } + else if (trueBlock != null) + { + var firstTrueNode = ProcessBlockRecursive(trueBlock, context, x + 300, targetY); + if (firstTrueNode != null && firstTrueNode.Id != branch.Id) + { + var truePin = branch.OutputPins.FirstOrDefault(p => p.Name == "True"); + var firstExecIn = firstTrueNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); + if (truePin != null && firstExecIn != null) + { + Console.WriteLine($"[DEBUG] ProcessBranchNodeConnections: Creating {branch.Name}.True -> {firstTrueNode.Name}.Exec"); + context.Blueprint.AddConnection(new BlueprintConnection + { + SourceNodeId = branch.Id, + SourcePinId = truePin.Id, + TargetNodeId = firstTrueNode.Id, + TargetPinId = firstExecIn.Id + }); + } + } + } + } + + // 处理 False 分支 + if (!string.IsNullOrEmpty(flowCtrl.FalseBlockName)) + { + var falseBlock = context.Script.GetBlockByName(flowCtrl.FalseBlockName); + if (falseBlock != null) + { + // ✅ 无论块是否已访问,都尝试获取第一个节点并建立连接 + // 对于已访问的块,从 BlockFirstNodes 获取;对于未访问的块,调用 ProcessBlockRecursive + BlueprintNode? firstFalseNode; + if (context.VisitedBlocks.Contains(falseBlock.Name)) + { + firstFalseNode = context.BlockFirstNodes.GetValueOrDefault(falseBlock.Name); + Console.WriteLine($"[DEBUG] ProcessBranchNodeConnections: falseBlock {falseBlock.Name} already visited, got first node from cache"); + } + else + { + firstFalseNode = ProcessBlockRecursive(falseBlock, context, x + 300, targetY + 200); + } + + Console.WriteLine($"[DEBUG] ProcessBranchNodeConnections: firstFalseNode = {firstFalseNode?.Name} ({firstFalseNode?.Id?.Substring(0,8) ?? "null"}), branch.Id = {branch.Id.Substring(0,8)}"); + + if (firstFalseNode == null) + { + Console.WriteLine($"[DEBUG] ProcessBranchNodeConnections: firstFalseNode is null, skipping"); + } + else if (firstFalseNode.Id == branch.Id) + { + Console.WriteLine($"[DEBUG] ProcessBranchNodeConnections: Skipping self-loop"); + } + else if (!context.Blueprint.Nodes.Any(n => n.Id == firstFalseNode.Id)) + { + Console.WriteLine($"[DEBUG] ProcessBranchNodeConnections: firstFalseNode NOT FOUND in Blueprint, skipping"); + } + else + { + // ✅ 如果目标是路由块(Branch/Loop),获取其实际可连接的子节点 + var actualTarget = GetFirstConnectableNode(firstFalseNode, context); + var falsePin = branch.OutputPins.FirstOrDefault(p => p.Name == "False"); + var actualExecIn = actualTarget?.InputPins.FirstOrDefault(p => p.Name == "Exec"); + + if (falsePin != null && actualExecIn != null && actualTarget != null && actualTarget.Id != branch.Id) + { + Console.WriteLine($"[DEBUG] ProcessBranchNodeConnections: Creating {branch.Name}.False -> {actualTarget.Name}.Exec"); + context.Blueprint.AddConnection(new BlueprintConnection + { + SourceNodeId = branch.Id, + SourcePinId = falsePin.Id, + TargetNodeId = actualTarget.Id, + TargetPinId = actualExecIn.Id + }); + } + } + } + } + + Console.WriteLine($"[DEBUG] ProcessBranchNodeConnections EXIT: callId={callId}, branch={branch.Id.Substring(0,8)}"); + } + + /// + /// 获取节点的可连接子节点。如果节点本身有来自外部(跨块)的 Exec 输入引脚连接,直接返回; + /// 如果是路由块(Branch/Loop)且其 Exec 输入只被内部连接,使用,则需要找到其连接的目标节点。 + /// + private BlueprintNode? GetFirstConnectableNode(BlueprintNode node, ConversionContext context) + { + // 如果节点本身有来自外部的 Exec 输入引脚连接,直接返回 + var execInputPin = node.InputPins.FirstOrDefault(p => p.Name == "Exec"); + if (execInputPin != null) + { + // 检查是否有从其他节点到这个节点 Exec 输入的连接(外部连接) + var hasExternalConnection = context.Blueprint.Connections + .Any(c => c.TargetNodeId == node.Id && + c.TargetPinId == execInputPin.Id && + c.SourceNodeId != node.Id); + if (hasExternalConnection) + { + return node; + } + } + + // 如果是 Branch 节点,找到其 True 输出连接的目标节点 + if (node is BranchNode branch) + { + var trueConn = context.Blueprint.Connections + .FirstOrDefault(c => c.SourceNodeId == branch.Id && + branch.OutputPins.Any(p => p.Id == c.SourcePinId && p.Name == "True")); + if (trueConn != null) + { + var targetNode = context.Blueprint.GetNodeById(trueConn.TargetNodeId); + if (targetNode != null) + { + // 递归获取实际可连接的节点 + return GetFirstConnectableNode(targetNode, context); + } + } + } + + // 如果是 Loop 节点,找到其 LoopBody 输出连接的目标节点 + if (node is LoopNode loop) + { + var loopBodyConn = context.Blueprint.Connections + .FirstOrDefault(c => c.SourceNodeId == loop.Id && + loop.OutputPins.Any(p => p.Id == c.SourcePinId && p.Name == "LoopBody")); + if (loopBodyConn != null) + { + var targetNode = context.Blueprint.GetNodeById(loopBodyConn.TargetNodeId); + if (targetNode != null) + { + return GetFirstConnectableNode(targetNode, context); + } + } + } + + return node; // 回退 + } + + private void ProcessLoopNodeConnections(LoopNode loop, BlockDefinition block, ConversionContext context, double x, double y) + { + // 找到对应的 FlowControlStatement + var flowCtrl = block.Statements.FirstOrDefault(s => s is FlowControlStatement) as FlowControlStatement; + if (flowCtrl == null) return; + + double targetY = y + 150; + + // 处理 LoopBody + if (!string.IsNullOrEmpty(flowCtrl.TrueBlockName)) + { + var loopBodyBlock = context.Script.GetBlockByName(flowCtrl.TrueBlockName); + if (loopBodyBlock != null) + { + // 注册 Loop 节点以便 LoopBodyEnd 引用 + context.LoopNodesByParentBlock[flowCtrl.TrueBlockName] = loop; + + var firstLoopNode = ProcessBlockRecursive(loopBodyBlock, context, x + 300, targetY); + if (firstLoopNode != null) + { + var loopBodyPin = loop.OutputPins.FirstOrDefault(p => p.Name == "LoopBody"); + var firstExecIn = firstLoopNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); + if (loopBodyPin != null && firstExecIn != null) + { + Console.WriteLine($"[DEBUG] ProcessLoopNodeConnections: Creating {loop.Name}.LoopBody -> {firstLoopNode.Name}.Exec"); + context.Blueprint.AddConnection(new BlueprintConnection + { + SourceNodeId = loop.Id, + SourcePinId = loopBodyPin.Id, + TargetNodeId = firstLoopNode.Id, + TargetPinId = firstExecIn.Id + }); + } + } + } + } + + // 处理 LoopEnd + if (!string.IsNullOrEmpty(flowCtrl.FalseBlockName)) + { + var afterLoopBlock = context.Script.GetBlockByName(flowCtrl.FalseBlockName); + if (afterLoopBlock != null) + { + var firstAfterLoopNode = ProcessBlockRecursive(afterLoopBlock, context, x + 300, targetY + 200); + if (firstAfterLoopNode != null) + { + var loopEndPin = loop.OutputPins.FirstOrDefault(p => p.Name == "LoopEnd"); + var firstExecIn = firstAfterLoopNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); + if (loopEndPin != null && firstExecIn != null) + { + Console.WriteLine($"[DEBUG] ProcessLoopNodeConnections: Creating {loop.Name}.LoopEnd -> {firstAfterLoopNode.Name}.Exec"); + context.Blueprint.AddConnection(new BlueprintConnection + { + SourceNodeId = loop.Id, + SourcePinId = loopEndPin.Id, + TargetNodeId = firstAfterLoopNode.Id, + TargetPinId = firstExecIn.Id + }); + } + } + } + } + + // 处理 LoopBodyEndReturnTo + if (!string.IsNullOrEmpty(flowCtrl.LoopBodyEndReturnTo)) + { + context.LoopNodesByParentBlock[flowCtrl.LoopBodyEndReturnTo] = loop; + } + } + + private BlueprintNode? CreateControlFlowNode(FlowControlStatement statement, ConversionContext context) + { + switch (statement.ControlType) + { + case FlowControlType.Branch: + return new BranchNode(); + case FlowControlType.Loop: + return new LoopNode(); + case FlowControlType.Break: + return new BreakNode(); + default: + return null; + } + } + + private static string GetMethodNameFromExpression(InvocationExpressionSyntax invoke) + { + if (invoke.Expression is IdentifierNameSyntax id) + return id.Identifier.Text; + if (invoke.Expression is GenericNameSyntax generic) + return generic.Identifier.Text; + if (invoke.Expression is MemberAccessExpressionSyntax member) + return member.Name.Identifier.Text; + return string.Empty; + } + + private BlueprintNode? CreateActionNodeFromExpression(string expression, ConversionContext context) + { + var parsed = TryParseInvocationExpression(expression); + if (parsed == null) + return null; + + var (funcName, args, assignment) = parsed.Value; + + if (funcName == "Get") + { + var varName = args?.Arguments.FirstOrDefault()?.Expression.ToString() ?? string.Empty; + + // ✅ 始终为 Get 创建独立的 GetNode + // 注意:不在此处添加到 Blueprint,由 ProcessBlockRecursive 统一添加 + var getNode = new GetNode + { + VarName = varName, + Name = $"Get:{varName}" + }; + Log.Debug("Created GetNode: VarName={VarName}, Id={NodeId}", varName, getNode.Id); + + var getValuePin = getNode.OutputPins.First(p => p.Name == "Value"); + + // ✅ 如果是赋值给 PubVar,注册数据源(使用唯一 key) + if (assignment != null) + { + var assignedVarName = assignment.Left.ToString(); + if (assignedVarName != "_") + { + var isPubVar = context.Script.PubVarBlock?.Variables + .Any(v => v.Name == assignedVarName) == true; + // ✅ 使用唯一 key(包含 Guid),避免嵌套复用 + context.VariableSources[$"__get_{assignedVarName}_{Guid.NewGuid():N}__"] = new VariableSource + { + Node = getNode, + Pin = getValuePin, + PubVarName = isPubVar ? assignedVarName : null + }; + Log.Debug("Registered GetNode output as VariableSource: {VarName}, IsPubVar={IsPubVar}", + assignedVarName, isPubVar); + } + } + + return getNode; // Return immediately — do NOT fall through to the CallNode block below + } + + if (funcName == "Set") + { + var argList = args?.Arguments.ToList() ?? new List(); + if (argList.Count >= 2) + { + var varName = argList[0].Expression.ToString(); + var valueExpr = argList[1].Expression; // ✅ 使用ExpressionSyntax而非ToString() + // 注意:不在此处添加到 Blueprint,由 ProcessBlockRecursive 统一添加 + var setNode = new SetNode + { + VarName = varName, + Name = $"Set:{varName}" + }; + Log.Debug("Created SetNode: VarName={VarName}, Id={NodeId}", varName, setNode.Id); + + // ✅ 获取Set节点的Value输入引脚 + var setValuePin = setNode.InputPins.FirstOrDefault(p => p.Name == "Value"); + if (setValuePin != null && valueExpr != null) + { + // ✅ 使用ProcessArgumentExpression处理嵌套函数调用 + ProcessArgumentExpression(valueExpr, setValuePin, setNode, context); + } + + return setNode; // Return immediately — do NOT fall through to the CallNode block below + } + return null; + } + + if (funcName == "Print") + { + var printNode = new PrintNode(); + var arg = args?.Arguments.FirstOrDefault()?.Expression.ToString() ?? string.Empty; + CreateDataConnectionsForExpression(arg, printNode, context); + return printNode; + } + + if (funcName == "Pause") + { + var pauseNode = new PauseNode(); + var arg = args?.Arguments.FirstOrDefault()?.Expression.ToString() ?? string.Empty; + CreateDataConnectionsForExpression(arg, pauseNode, context); + return pauseNode; + } + + if (assignment != null) + { + var varName = assignment.Left.ToString(); + + if (funcName is "Loop" or "Branch" or "LoopBodyEnd" or "Break") + { + Log.Debug("Skipped creating CallNode for flow control function: {FuncName}", funcName); + return null; + } + + // ✅ 检查是否应该复用已有的 VariableSource + // 如果 varName 已经有一个来源(相同函数的调用),则复用;否则创建新的 + var existingSource = context.VariableSources.Values + .FirstOrDefault(s => s.PubVarName == varName); + + BlueprintNode callNode; + if (existingSource != null) + { + // ✅ 复用已有的源节点 + callNode = existingSource.Node; + Log.Debug("Reused existing source node {NodeName} for {VarName}", callNode.Name, varName); + } + else + { + // ✅ 创建新的 Call 节点 + var isHelper = context.Script.HelperFunctions.Any(h => h.Name == funcName); + if (isHelper) + callNode = new CallHelperNode { HelperFunctionName = funcName, Name = $"Helper:{funcName}" }; + else + callNode = new CallNode { FunctionName = funcName, Name = $"Call:{funcName}" }; + + AddParameterPins(callNode, args?.Arguments); + CreateDataConnectionsForArguments(args?.Arguments, callNode, context); + + // ✅ 注册新的数据源 + var returnPin = callNode.OutputPins.FirstOrDefault(p => p.Name == "Return"); + if (returnPin != null) + { + // Check if varName (assignment target) is a PubVar + var isPubVar = context.Script.PubVarBlock?.Variables + .Any(v => v.Name == varName) == true; + context.VariableSources[varName] = new VariableSource + { + Node = callNode, + Pin = returnPin, + PubVarName = isPubVar ? varName : null // ✅ Set PubVarName for PubVar + }; + } + } + + return callNode; + } + + if (funcName is "Loop" or "Branch" or "LoopBodyEnd" or "Break") + { + Log.Debug("Skipped creating CallNode for flow control/builtin function: {FuncName}", funcName); + return null; + } + + BlueprintNode callNode2; + var isHelper2 = context.Script.HelperFunctions.Any(h => h.Name == funcName); + if (isHelper2) + callNode2 = new CallHelperNode { HelperFunctionName = funcName, Name = $"Helper:{funcName}" }; + else + callNode2 = new CallNode { FunctionName = funcName, Name = $"Call:{funcName}" }; + + AddParameterPins(callNode2, args?.Arguments); + CreateDataConnectionsForArguments(args?.Arguments, callNode2, context); + + return callNode2; + } + + private (string? funcName, ArgumentListSyntax? args, AssignmentExpressionSyntax? assignment)? TryParseInvocationExpression(string expression) + { + var wrappedCode = $"_ = {expression};"; + var syntaxTree = CSharpSyntaxTree.ParseText(wrappedCode, cancellationToken: CancellationToken.None); + var root = syntaxTree.GetCompilationUnitRoot(); + + var globalStmt = root.Members.FirstOrDefault() as GlobalStatementSyntax; + var stmt = globalStmt?.Statement as ExpressionStatementSyntax; + + if (stmt?.Expression is AssignmentExpressionSyntax outerAssignment) + { + ExpressionSyntax rightExpr = outerAssignment.Right; + // Track the innermost assignment that has a non-underscore variable on the left + AssignmentExpressionSyntax? innermostAssignment = null; + while (rightExpr is AssignmentExpressionSyntax nestedAssignment) + { + innermostAssignment = nestedAssignment; + rightExpr = nestedAssignment.Right; + } + + if (rightExpr is InvocationExpressionSyntax invoke) + { + var methodName = GetMethodNameFromExpression(invoke); + // Use the innermost assignment if available, otherwise the outermost + var assignmentToReturn = innermostAssignment ?? outerAssignment; + return (methodName, invoke.ArgumentList, assignmentToReturn); + } + } + else if (stmt?.Expression is InvocationExpressionSyntax directInvoke) + { + var methodName = GetMethodNameFromExpression(directInvoke); + return (methodName, directInvoke.ArgumentList, null); + } + return null; + } + + private void AddParameterPins(BlueprintNode node, SeparatedSyntaxList? arguments) + { + if (!arguments.HasValue || arguments.Value.Count == 0) return; + + foreach (var arg in arguments.Value) + { + node.InputPins.Add(new BlueprintPin + { + Name = $"param{node.InputPins.Count}", + Direction = PinDirection.Input, + Type = PinType.Any + }); + } + } + + private void ProcessLoopBlock(BlockDefinition block, ConversionContext context) + { + if (context.VisitedBlocks.Contains(block.Name)) return; + context.VisitedBlocks.Add(block.Name); + + // ✅ 设置当前父块名称,以便复用 Loop 节点 + var parentBlockName = block.ParentBlockName ?? block.Name; + context.CurrentParentBlock = parentBlockName; + + var nodes = new List(); + var currentY = 50.0; + + foreach (var statement in block.Statements) + { + var node = ProcessStatement(statement, context, 50, currentY); + if (node != null) + { + context.Blueprint.AddNode(node); + nodes.Add(node); + currentY += 120; + } + } + + LinkNodesWithExec(nodes, context.Blueprint); + + foreach (var statement in block.Statements) + { + if (statement is FlowControlStatement flowCtrl) + { + if (!string.IsNullOrEmpty(flowCtrl.TrueBlockName)) + { + var trueBlock = context.Script.GetBlockByName(flowCtrl.TrueBlockName); + if (trueBlock != null) + ProcessBlockRecursive(trueBlock, context, 350, 50); + } + if (!string.IsNullOrEmpty(flowCtrl.FalseBlockName)) + { + var falseBlock = context.Script.GetBlockByName(flowCtrl.FalseBlockName); + if (falseBlock != null) + ProcessBlockRecursive(falseBlock, context, 350, 200); + } + } + } + } + + private void LinkNodesWithExec(List nodes, Contract.Workflow.Blueprint blueprint) + { + Console.WriteLine($"[DEBUG] === LinkNodesWithExec called with {nodes.Count} nodes: {string.Join(", ", nodes.Select(n => $"{n.Name}({n.Id.Substring(0,8)})"))}"); + + for (int i = 0; i < nodes.Count - 1; i++) + { + var currentNode = nodes[i]; + var nextNode = nodes[i + 1]; + + // ✅ 跳过自环连接 + if (currentNode.Id == nextNode.Id) + { + Console.WriteLine($"[DEBUG] LinkNodesWithExec: Skipping self-loop for {currentNode.Name} ({currentNode.Id.Substring(0,8)})"); + continue; + } + + // ✅ 获取 currentNode 的 Exec 输出引脚 + var currentExecOut = currentNode.OutputPins.FirstOrDefault(p => p.Name == "Exec"); + + // ✅ Branch 和 Loop 节点没有 Exec 输出引脚,跳过 + if (currentExecOut == null) + { + Log.Debug("Skipping Exec link: {Node} has no Exec output pin", currentNode.Name); + continue; + } + + // ✅ 确保 currentExecOut 是 OUTPUT 类型的引脚 + if (currentExecOut.Direction != PinDirection.Output) + { + Log.Warning("Cannot create Exec connection: {Node}.Exec is not a valid output pin", currentNode.Name); + continue; + } + + // ✅ 获取 nextNode 的 Exec 输入引脚 + var nextExecIn = nextNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); + if (nextExecIn == null) + { + Log.Warning("Cannot create Exec connection: {Node} has no Exec input pin", nextNode.Name); + continue; + } + + Console.WriteLine($"[DEBUG] LinkNodesWithExec: Creating {currentNode.Name}.{currentExecOut.Name} -> {nextNode.Name}.{nextExecIn.Name}"); + + blueprint.AddConnection(new BlueprintConnection + { + SourceNodeId = currentNode.Id, + SourcePinId = currentExecOut.Id, + TargetNodeId = nextNode.Id, + TargetPinId = nextExecIn.Id + }); + } + } + + private void LayoutNodes(Contract.Workflow.Blueprint blueprint) + { + var depths = new Dictionary(); + var visited = new HashSet(); + + var entry = blueprint.Nodes.FirstOrDefault(n => n.NodeType == BlueprintNodeType.Entry); + if (entry != null) + { + depths[entry.Id] = 0; + CalculateDepths(blueprint, entry, depths, visited); + } + + var depthGroups = new Dictionary>(); + foreach (var node in blueprint.Nodes) + { + if (depths.TryGetValue(node.Id, out var depth)) + { + if (!depthGroups.ContainsKey(depth)) + depthGroups[depth] = new List(); + depthGroups[depth].Add(node); + } + } + + foreach (var group in depthGroups) + { + var depth = group.Key; + var nodes = group.Value; + for (int i = 0; i < nodes.Count; i++) + { + nodes[i].X = 50 + depth * 300; + nodes[i].Y = 50 + i * 120; + } + } + } + + private void CalculateDepths(Contract.Workflow.Blueprint blueprint, BlueprintNode node, Dictionary depths, HashSet visited) + { + if (visited.Contains(node.Id)) return; + visited.Add(node.Id); + + var outConnections = blueprint.GetConnectionsFrom(node.Id); + foreach (var conn in outConnections) + { + if (blueprint.GetNodeById(conn.TargetNodeId) is { } targetNode) + { + var currentDepth = depths[node.Id]; + if (!depths.ContainsKey(targetNode.Id) || depths[targetNode.Id] < currentDepth + 1) + { + depths[targetNode.Id] = currentDepth + 1; + } + CalculateDepths(blueprint, targetNode, depths, visited); + } + } + } + + private bool IsVariableReference(string token) + { + if (string.IsNullOrWhiteSpace(token)) return false; + if (bool.TryParse(token, out _)) return false; + if (int.TryParse(token, out _) || double.TryParse(token, out _)) return false; + if (token.StartsWith("\"") && token.EndsWith("\"")) return false; + if (token.StartsWith("'") && token.EndsWith("'")) return false; + if (token.Contains('(')) return false; + return System.Text.RegularExpressions.Regex.IsMatch(token, @"^[a-zA-Z_]\w*$"); + } + + private void CreateDataConnectionsForExpression(string expression, BlueprintNode targetNode, ConversionContext context) + { + var wrappedCode = $"_ = {expression};"; + var syntaxTree = CSharpSyntaxTree.ParseText(wrappedCode, cancellationToken: CancellationToken.None); + var root = syntaxTree.GetCompilationUnitRoot(); + var globalStmt = root.Members.FirstOrDefault() as GlobalStatementSyntax; + var stmt = globalStmt?.Statement as ExpressionStatementSyntax; + + if (stmt?.Expression == null) return; + + ProcessExpressionForDataConnections(stmt.Expression, targetNode, context); + } + + private void ProcessExpressionForDataConnections(ExpressionSyntax expr, BlueprintNode targetNode, ConversionContext context) + { + switch (expr) + { + case LiteralExpressionSyntax literal: + CreateConstNodeForLiteral(literal, targetNode, context); + break; + + case IdentifierNameSyntax identifier: + CreateDataConnectionForVariable(identifier.Identifier.Text, targetNode, context); + break; + + case AssignmentExpressionSyntax assignment: + ProcessExpressionForDataConnections(assignment.Right, targetNode, context); + break; + + case InvocationExpressionSyntax invoke: + var funcName = GetMethodNameFromExpression(invoke); + + // ✅ 注意:Get 和 Set 的处理在 ProcessArgumentExpression 中已正确完成 + // 这里只需要处理其他函数调用(HelperFuncAdd 等)的情况 + if (funcName != "Get" && funcName != "Set") + { + // ✅ 始终创建新的 Call 节点(不复用) + // 因为 condition 表达式(BEQ/BLT/BLE)是不同的函数调用,不应该复用 + var isHelper = context.Script.HelperFunctions.Any(h => h.Name == funcName); + BlueprintNode callNode; + if (isHelper) + callNode = new CallHelperNode { HelperFunctionName = funcName, Name = $"Helper:{funcName}" }; + else + callNode = new CallNode { FunctionName = funcName, Name = $"Call:{funcName}" }; + + AddParameterPins(callNode, invoke.ArgumentList.Arguments); + context.Blueprint.AddNode(callNode); + + // ✅ 处理参数的连接 + CreateDataConnectionsForArguments(invoke.ArgumentList.Arguments, callNode, context); + + // ✅ 将函数调用的返回值连接到 targetNode 的条件引脚 + var returnPin = callNode.OutputPins.FirstOrDefault(p => p.Name == "Return"); + var targetPin = targetNode.InputPins.FirstOrDefault(p => p.Name != "Exec"); + if (returnPin != null && targetPin != null) + { + context.Blueprint.AddConnection(new BlueprintConnection + { + SourceNodeId = callNode.Id, + SourcePinId = returnPin.Id, + TargetNodeId = targetNode.Id, + TargetPinId = targetPin.Id, + PubVarName = null + }); + } + } + break; + + default: + var exprStr = expr.ToString(); + if (IsVariableReference(exprStr)) + { + CreateDataConnectionForVariable(exprStr, targetNode, context); + } + break; + } + } + + private void CreateConstNodeForLiteral(LiteralExpressionSyntax literal, BlueprintNode targetNode, ConversionContext context) + { + var targetPin = targetNode.InputPins.FirstOrDefault(p => p.Name != "Exec"); + if (targetPin == null) return; + + if (literal.Token.Value is string strVal) + { + // ✅ 不再创建 ConstNode,直接设置预设值 + targetPin.DefaultValue = strVal; + Log.Debug("Set DefaultValue for {TargetNode}.{TargetPin}: {Value}", targetNode.Name, targetPin.Name, strVal); + } + else if (literal.Token.Value is int intVal) + { + // ✅ 不再创建 ConstNode,直接设置预设值 + targetPin.DefaultValue = intVal.ToString(); + Log.Debug("Set DefaultValue for {TargetNode}.{TargetPin}: {Value}", targetNode.Name, targetPin.Name, intVal); + } + } + + /// + /// Sets DefaultValue for a literal and connects it to a specific parameter pin. + /// Used by ProcessArgumentExpression to set literal defaults for exact parameter pins. + /// + private void CreateConstNodeForLiteralWithPin(LiteralExpressionSyntax literal, BlueprintPin targetPin, BlueprintNode targetNode, ConversionContext context) + { + if (literal.Token.Value is string strVal) + { + // ✅ 不再创建 ConstNode,直接设置预设值 + targetPin.DefaultValue = strVal; + Log.Debug("Set DefaultValue for {TargetNode}.{TargetPin}: {Value}", targetNode.Name, targetPin.Name, strVal); + } + else if (literal.Token.Value is int intVal) + { + // ✅ 不再创建 ConstNode,直接设置预设值 + targetPin.DefaultValue = intVal.ToString(); + Log.Debug("Set DefaultValue for {TargetNode}.{TargetPin}: {Value}", targetNode.Name, targetPin.Name, intVal); + } + } + + private void CreateDataConnectionsForArguments(SeparatedSyntaxList? arguments, BlueprintNode callNode, ConversionContext context) + { + if (!arguments.HasValue || arguments.Value.Count == 0) return; + + // NOTE: AddParameterPins names parameters using node.InputPins.Count at the time of creation. + // Since CallNode/CallHelperNode already has Exec (at count=0), the first parameter gets name "param1". + // Therefore paramIndex starts at 1 to match the naming convention. + var paramIndex = 1; + foreach (var arg in arguments.Value) + { + var paramPin = callNode.InputPins.FirstOrDefault(p => p.Name == $"param{paramIndex}"); + if (paramPin == null) break; + paramIndex++; + ProcessArgumentExpression(arg.Expression, paramPin, callNode, context); + } + } + + private void ProcessArgumentExpression(ExpressionSyntax expr, BlueprintPin targetPin, BlueprintNode callNode, ConversionContext context) + { + switch (expr) + { + case InvocationExpressionSyntax invoke: + var funcName = GetMethodNameFromExpression(invoke); + Log.Debug("[ProcessArgumentExpression] InvocationExpressionSyntax: funcName={FuncName}, expr={Expr}", + funcName, expr.ToString()); + + if (funcName == "Get") + { + var varName = invoke.ArgumentList.Arguments.FirstOrDefault()?.Expression.ToString() ?? string.Empty; + + // ✅ Check if varName is a PubVarBlock variable + var isPubVar = context.Script.PubVarBlock?.Variables + .Any(v => v.Name == varName) == true; + + if (isPubVar) + { + // ✅ PubVar: 不创建 GetNode,直接从 VariableSources 获取 + // 查找时使用简单变量名(PubVarNames 中记录的名称) + var source = context.VariableSources.Values + .FirstOrDefault(s => s.PubVarName == varName); + if (source != null) + { + context.Blueprint.AddConnection(new BlueprintConnection + { + SourceNodeId = source.Node.Id, + SourcePinId = source.Pin.Id, + TargetNodeId = callNode.Id, + TargetPinId = targetPin.Id, + PubVarName = varName // ✅ PubVar 变量设置 PubVarName + }); + } + else + { + Log.Warning("[ProcessArgumentExpression] PubVar {VarName} not found in VariableSources", varName); + } + } + else + { + // ✅ ConstBlock variable: 创建独立的 GetNode(不连接到 ConstNode) + var getNode = new GetNode { VarName = varName, Name = $"Get:{varName}" }; + context.Blueprint.AddNode(getNode); + var getValuePin = getNode.OutputPins.First(p => p.Name == "Value"); + + // 连接到目标参数(GetNode 通过 VarName 在运行时获取 ConstBlock 值) + context.Blueprint.AddConnection(new BlueprintConnection + { + SourceNodeId = getNode.Id, + SourcePinId = getValuePin.Id, + TargetNodeId = callNode.Id, + TargetPinId = targetPin.Id, + PubVarName = null // ✅ ConstBlock 变量 + }); + } + } + else if (funcName == "Set") + { + var args = invoke.ArgumentList.Arguments; + if (args.Count >= 2) + { + var varName = args[0].Expression.ToString(); + var valueExpr = args[1].Expression; + var setNode = new SetNode { VarName = varName, Name = $"Set:{varName}" }; + context.Blueprint.AddNode(setNode); + var setValuePin = setNode.InputPins.FirstOrDefault(p => p.Name == "Value"); + if (setValuePin != null) + { + ProcessArgumentExpression(valueExpr, setValuePin, setNode, context); + } + } + } + else + { + var isHelper = context.Script.HelperFunctions.Any(h => h.Name == funcName); + BlueprintNode innerCallNode = isHelper + ? new CallHelperNode { HelperFunctionName = funcName, Name = $"Helper:{funcName}" } + : new CallNode { FunctionName = funcName, Name = $"Call:{funcName}" }; + + AddParameterPins(innerCallNode, invoke.ArgumentList.Arguments); + context.Blueprint.AddNode(innerCallNode); + CreateDataConnectionsForArguments(invoke.ArgumentList.Arguments, innerCallNode, context); + + var returnPin = innerCallNode.OutputPins.FirstOrDefault(p => p.Name == "Return"); + if (returnPin != null) + { + context.Blueprint.AddConnection(new BlueprintConnection + { + SourceNodeId = innerCallNode.Id, + SourcePinId = returnPin.Id, + TargetNodeId = callNode.Id, + TargetPinId = targetPin.Id, + PubVarName = null + }); + context.VariableSources[$"__call_{funcName}_{Guid.NewGuid():N}__"] = new VariableSource + { + Node = innerCallNode, + Pin = returnPin, + PubVarName = null + }; + } + } + break; + + case LiteralExpressionSyntax literal: + CreateConstNodeForLiteralWithPin(literal, targetPin, callNode, context); + break; + + case IdentifierNameSyntax identifier: + CreateDataConnectionForVariable(identifier.Identifier.Text, targetPin, callNode, context); + break; + + default: + var exprStr = expr.ToString(); + if (IsVariableReference(exprStr)) + { + CreateDataConnectionForVariable(exprStr, targetPin, callNode, context); + } + break; + } + } + + private void CreateDataConnectionForVariable(string varName, BlueprintPin targetPin, BlueprintNode callNode, ConversionContext context) + { + if (context.Script.ConstBlock?.Variables.Any(v => v.Name == varName) == true) + { + // ✅ ConstBlock 变量:直接从 ConstNode 建立连接到目标节点 + var constNode = context.Blueprint.Nodes.OfType().FirstOrDefault(c => c.ConstName == varName); + if (constNode != null) + { + var constPin = constNode.OutputPins.First(p => p.Name == "Value"); + context.Blueprint.AddConnection(new BlueprintConnection + { + SourceNodeId = constNode.Id, + SourcePinId = constPin.Id, + TargetNodeId = callNode.Id, + TargetPinId = targetPin.Id, + PubVarName = null // ✅ ConstBlock 变量不应设置 PubVarName + }); + } + } + else + { + // ✅ PubVar 或其他变量:按 PubVarName 查找 VariableSources + var source = context.VariableSources.Values.FirstOrDefault(s => s.PubVarName == varName); + if (source != null) + { + context.Blueprint.AddConnection(new BlueprintConnection + { + SourceNodeId = source.Node.Id, + SourcePinId = source.Pin.Id, + TargetNodeId = callNode.Id, + TargetPinId = targetPin.Id, + PubVarName = source.PubVarName + }); + } + } + } + + private void CreateDataConnectionForVariable(string varName, BlueprintNode targetNode, ConversionContext context) + { + var targetPin = targetNode.InputPins.FirstOrDefault(p => p.Name != "Exec"); + if (targetPin == null) return; + + if (context.Script.ConstBlock?.Variables.Any(v => v.Name == varName) == true) + { + // ✅ ConstBlock 变量:直接从 ConstNode 建立连接到目标节点 + var constNode = context.Blueprint.Nodes.OfType().FirstOrDefault(c => c.ConstName == varName); + if (constNode != null) + { + var constPin = constNode.OutputPins.First(p => p.Name == "Value"); + context.Blueprint.AddConnection(new BlueprintConnection + { + SourceNodeId = constNode.Id, + SourcePinId = constPin.Id, + TargetNodeId = targetNode.Id, + TargetPinId = targetPin.Id, + PubVarName = null // ✅ ConstBlock 变量不应设置 PubVarName + }); + } + } + else + { + // ✅ PubVar 或其他变量:按 PubVarName 查找 VariableSources + var source = context.VariableSources.Values.FirstOrDefault(s => s.PubVarName == varName); + if (source != null) + { + context.Blueprint.AddConnection(new BlueprintConnection + { + SourceNodeId = source.Node.Id, + SourcePinId = source.Pin.Id, + TargetNodeId = targetNode.Id, + TargetPinId = targetPin.Id, + PubVarName = source.PubVarName + }); + } + } + } + + public class VariableSource + { + public BlueprintNode Node { get; set; } = null!; + public BlueprintPin Pin { get; set; } = null!; + /// 如果是 PubVar,则记录 PubVarName;否则为 null + public string? PubVarName { get; set; } + } + + internal class ConversionContext + { + public required Contract.Workflow.Blueprint Blueprint { get; set; } + public required BlockScript Script { get; set; } + public Dictionary NodeMap { get; set; } = new(); + public Dictionary NamedBlockMap { get; set; } = new(); + public Dictionary BlockFirstNodes { get; set; } = new(); + public EntryNode? EntryNode { get; set; } + public int NextPubVarIndex { get; set; } + public HashSet VisitedBlocks { get; set; } = new(); + public Dictionary VariableSources { get; set; } = new(); + public List DataConnections { get; set; } = new(); + public Dictionary LoopNodesByParentBlock { get; set; } = new(); + public BlueprintNode? LastProcessedNode { get; set; } + /// + /// 当前正在处理的父块名称(用于 Loop 节点复用) + /// + public string? CurrentParentBlock { get; set; } + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintService.cs new file mode 100644 index 00000000..dba55916 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintService.cs @@ -0,0 +1,120 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using KitX.Core.Contract.Workflow; +using Serilog; + +namespace KitX.Core.Workflow.Blueprint; + +/// +/// Blueprint service implementation +/// +public class BlueprintService : IBlueprintService +{ + private readonly IBlockScriptParser _parser; + private readonly IBlockScriptExecutor _executor; + + /// + /// Creates a new BlueprintService instance + /// + public BlueprintService(IBlockScriptParser parser, IBlockScriptExecutor executor) + { + _parser = parser; + _executor = executor; + } + + /// + /// Creates a new empty blueprint + /// + /// New blueprint + public Contract.Workflow.Blueprint CreateBlueprint() + { + Log.Information("Creating new Blueprint"); + return new Contract.Workflow.Blueprint + { + Name = "Untitled", + CreatedAt = DateTime.Now, + ModifiedAt = DateTime.Now + }; + } + + /// + /// Imports blueprint from BlockScript source code + /// + /// BlockScript source code + /// Helper functions available + /// Imported blueprint, or null if conversion failed + public Contract.Workflow.Blueprint? ImportFromBlockScript(string sourceCode, List? helperFunctions = null) + { + try + { + Log.Information("Importing Blueprint from BlockScript"); + + var converter = new BlockScriptToBlueprintConverter(_parser); + var blueprint = converter.Convert(sourceCode, helperFunctions); + + blueprint.ModifiedAt = DateTime.Now; + return blueprint; + } + catch (Exception ex) + { + Log.Error(ex, "Failed to import Blueprint from BlockScript"); + return null; + } + } + + /// + /// Exports blueprint to BlockScript source code + /// + /// Blueprint to export + /// BlockScript source code + public string ExportToBlockScript(Contract.Workflow.Blueprint blueprint) + { + try + { + Log.Information("Exporting Blueprint to BlockScript"); + + var converter = new BlueprintToBlockScriptConverter(); + return converter.Convert(blueprint); + } + catch (Exception ex) + { + Log.Error(ex, "Failed to export Blueprint to BlockScript"); + throw; + } + } + + /// + /// Executes blueprint by converting to BlockScript and running + /// + /// Blueprint to execute + /// Execution result + public async Task ExecuteBlueprintAsync(Contract.Workflow.Blueprint blueprint) + { + try + { + Log.Information("Executing Blueprint"); + + // Convert blueprint to BlockScript + var converter = new BlueprintToBlockScriptConverter(); + var blockScript = converter.ConvertToBlockScript(blueprint); + + // Execute the BlockScript + var result = await _executor.ExecuteAsync( + blockScript, + null, + CancellationToken.None); + + return result; + } + catch (Exception ex) + { + Log.Error(ex, "Failed to execute Blueprint"); + return new BlockScriptExecutionResult + { + IsSuccess = false, + ErrorMessage = ex.Message + }; + } + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintToBlockScriptConverter.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintToBlockScriptConverter.cs new file mode 100644 index 00000000..1f695b7e --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintToBlockScriptConverter.cs @@ -0,0 +1,578 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using KitX.Core.Contract.Workflow; + +namespace KitX.Core.Workflow.Blueprint; + +/// +/// Converts Blueprint to BlockScript +/// +public class BlueprintToBlockScriptConverter : IBlueprintToBlockScriptConverter +{ + public string Convert(Contract.Workflow.Blueprint blueprint) + { + return ConvertToBlockScript(blueprint).SourceCode; + } + + public BlockScript ConvertToBlockScript(Contract.Workflow.Blueprint blueprint) + { + var context = new ReverseConversionContext + { + Blueprint = blueprint, + Script = new BlockScript(), + PubVarIndex = 0, + BlockIndex = 0 + }; + + // 1. Generate #PubVarBlock with all PubVars + GeneratePubVarBlock(context); + + // 2. Generate #ConstBlock + GenerateConstBlock(context); + + // 3. Find Entry node and process main execution flow + var entryNode = blueprint.Nodes.FirstOrDefault(n => n.NodeType == BlueprintNodeType.Entry); + if (entryNode != null) + { + // Get nodes in main execution flow (from Entry to first control flow) + var mainFlow = GetMainExecutionFlow(blueprint, entryNode); + var mainBlock = GenerateBlock(context, mainFlow, "MainBlock"); + context.Script.MainBlock = mainBlock; + } + + // 4. Process sub-graphs (branches, loops) as NamedBlocks + ProcessSubGraphs(blueprint, context); + + // 5. Generate source code + GenerateSourceCode(context.Script); + + return context.Script; + } + + private void GeneratePubVarBlock(ReverseConversionContext context) + { + var pubVarBlock = new BlockDefinition + { + Type = BlockType.PubVarBlock, + Name = "PubVarBlock" + }; + + // Also create PubVars for data connections that don't have explicit PubVar + var dataConnections = context.Blueprint.Connections + .Where(c => !IsExecConnection(context.Blueprint, c)) + .ToList(); + + foreach (var conn in dataConnections) + { + if (string.IsNullOrEmpty(conn.PubVarName)) + { + // Create a new PubVar for this connection + var pubVarName = $"temp_{context.PubVarIndex++}"; + conn.PubVarName = pubVarName; + pubVarBlock.Variables.Add(new VariableDeclaration + { + Name = pubVarName, + Type = "object" + }); + context.Blueprint.PubVarNames.Add(pubVarName); + } + } + + if (pubVarBlock.Variables.Count > 0) + { + context.Script.PubVarBlock = pubVarBlock; + } + } + + private void GenerateConstBlock(ReverseConversionContext context) + { + var constBlock = new BlockDefinition + { + Type = BlockType.ConstBlock, + Name = "ConstBlock" + }; + + foreach (var node in context.Blueprint.Nodes) + { + if (node is ConstNode constNode) + { + constBlock.Variables.Add(new VariableDeclaration + { + Name = constNode.ConstName, + Type = constNode.ConstType, + DefaultValue = constNode.ConstValue + }); + } + } + + if (constBlock.Variables.Count > 0) + { + context.Script.ConstBlock = constBlock; + } + } + + private List GetMainExecutionFlow(Contract.Workflow.Blueprint blueprint, BlueprintNode entryNode) + { + var flow = new List(); + var visited = new HashSet(); + CollectMainFlow(blueprint, entryNode, flow, visited, out _); + return flow; + } + + private void CollectMainFlow(Contract.Workflow.Blueprint blueprint, BlueprintNode node, List flow, + HashSet visited, out bool hitControlFlow) + { + if (visited.Contains(node.Id)) + { + hitControlFlow = false; + return; + } + visited.Add(node.Id); + + flow.Add(node); + + // Check if this is a control flow node + if (node is BranchNode || node is LoopNode) + { + hitControlFlow = true; + return; + } + + // Get next Exec connection + var execOut = node.OutputPins.FirstOrDefault(p => p.Name == "Exec"); + if (execOut == null) + { + hitControlFlow = false; + return; + } + + var execConn = blueprint.Connections + .FirstOrDefault(c => c.SourcePinId == execOut.Id); + + if (execConn == null) + { + hitControlFlow = false; + return; + } + + var nextNode = blueprint.GetNodeById(execConn.TargetNodeId); + if (nextNode == null) + { + hitControlFlow = false; + return; + } + + CollectMainFlow(blueprint, nextNode, flow, visited, out hitControlFlow); + } + + private void ProcessSubGraphs(Contract.Workflow.Blueprint blueprint, ReverseConversionContext context) + { + var processedTargets = new HashSet(); + + foreach (var node in blueprint.Nodes) + { + if (node is BranchNode branch) + { + ProcessBranchSubGraphs(blueprint, branch, context, processedTargets); + } + else if (node is LoopNode loop) + { + ProcessLoopSubGraphs(blueprint, loop, context, processedTargets); + } + } + } + + private void ProcessBranchSubGraphs(Contract.Workflow.Blueprint blueprint, BranchNode branch, + ReverseConversionContext context, HashSet processedTargets) + { + var truePin = branch.OutputPins.FirstOrDefault(p => p.Name == "True"); + var falsePin = branch.OutputPins.FirstOrDefault(p => p.Name == "False"); + + // True branch + if (truePin != null) + { + var trueConn = blueprint.Connections.FirstOrDefault(c => c.SourcePinId == truePin.Id); + if (trueConn != null && !processedTargets.Contains(trueConn.TargetNodeId)) + { + var targetNode = blueprint.GetNodeById(trueConn.TargetNodeId); + if (targetNode != null) + { + var blockName = $"Block_{context.BlockIndex++}"; + var block = CollectSubGraph(blueprint, targetNode, context, processedTargets); + context.Script.NamedBlocks[blockName] = block; + processedTargets.Add(trueConn.TargetNodeId); + + // Set the target block name on the corresponding FlowControlStatement + if (context.ControlFlowMap.TryGetValue(branch.Id, out var flow)) + { + flow.TrueBlockName = blockName; + } + } + } + } + + // False branch + if (falsePin != null) + { + var falseConn = blueprint.Connections.FirstOrDefault(c => c.SourcePinId == falsePin.Id); + if (falseConn != null && !processedTargets.Contains(falseConn.TargetNodeId)) + { + var targetNode = blueprint.GetNodeById(falseConn.TargetNodeId); + if (targetNode != null) + { + var blockName = $"Block_{context.BlockIndex++}"; + var block = CollectSubGraph(blueprint, targetNode, context, processedTargets); + context.Script.NamedBlocks[blockName] = block; + processedTargets.Add(falseConn.TargetNodeId); + + // Set the target block name on the corresponding FlowControlStatement + if (context.ControlFlowMap.TryGetValue(branch.Id, out var flow)) + { + flow.FalseBlockName = blockName; + } + } + } + } + } + + private void ProcessLoopSubGraphs(Contract.Workflow.Blueprint blueprint, LoopNode loop, + ReverseConversionContext context, HashSet processedTargets) + { + var loopBodyPin = loop.OutputPins.FirstOrDefault(p => p.Name == "LoopBody"); + var loopEndPin = loop.OutputPins.FirstOrDefault(p => p.Name == "LoopEnd"); + + // Loop body + if (loopBodyPin != null) + { + var bodyConn = blueprint.Connections.FirstOrDefault(c => c.SourcePinId == loopBodyPin.Id); + if (bodyConn != null && !processedTargets.Contains(bodyConn.TargetNodeId)) + { + var targetNode = blueprint.GetNodeById(bodyConn.TargetNodeId); + if (targetNode != null) + { + var blockName = $"Block_{context.BlockIndex++}"; + var block = CollectSubGraph(blueprint, targetNode, context, processedTargets, loop.Id); + context.Script.NamedBlocks[blockName] = block; + processedTargets.Add(bodyConn.TargetNodeId); + + // Set the target block name on the corresponding FlowControlStatement + if (context.ControlFlowMap.TryGetValue(loop.Id, out var flow)) + { + flow.TrueBlockName = blockName; + } + } + } + } + + // Loop end (after loop) + if (loopEndPin != null) + { + var endConn = blueprint.Connections.FirstOrDefault(c => c.SourcePinId == loopEndPin.Id); + if (endConn != null && !processedTargets.Contains(endConn.TargetNodeId)) + { + var targetNode = blueprint.GetNodeById(endConn.TargetNodeId); + if (targetNode != null) + { + var blockName = $"Block_{context.BlockIndex++}"; + var block = CollectSubGraph(blueprint, targetNode, context, processedTargets); + context.Script.NamedBlocks[blockName] = block; + processedTargets.Add(endConn.TargetNodeId); + + // Set the target block name on the corresponding FlowControlStatement + if (context.ControlFlowMap.TryGetValue(loop.Id, out var flow)) + { + flow.FalseBlockName = blockName; + } + } + } + } + } + + private BlockDefinition CollectSubGraph(Contract.Workflow.Blueprint blueprint, BlueprintNode startNode, + ReverseConversionContext context, HashSet processedTargets, string? loopbackTargetId = null) + { + var block = new BlockDefinition + { + Type = BlockType.NamedBlock, + Name = startNode.NodeType.ToString() + }; + + var visited = new HashSet(); + CollectNodesRecursive(blueprint, startNode, block, context, visited, processedTargets, loopbackTargetId); + + return block; + } + + private void CollectNodesRecursive(Contract.Workflow.Blueprint blueprint, BlueprintNode node, + BlockDefinition block, ReverseConversionContext context, HashSet visited, HashSet processedTargets, + string? loopbackTargetId) + { + if (visited.Contains(node.Id)) return; + visited.Add(node.Id); + + // Add node to block + var nodeStatement = ConvertNodeToStatement(node, blueprint); + if (nodeStatement != null) + { + block.Statements.Add(nodeStatement); + + // Track FlowControlStatements for later setting target block names + if (nodeStatement is FlowControlStatement flow) + { + context.ControlFlowMap[node.Id] = flow; + } + } + + // Process all output connections + foreach (var outPin in node.OutputPins) + { + if (outPin.Name == "Exec") + { + var conn = blueprint.Connections.FirstOrDefault(c => c.SourcePinId == outPin.Id); + if (conn != null) + { + var nextNode = blueprint.GetNodeById(conn.TargetNodeId); + if (nextNode != null) + { + // Check if this is a loopback to the loop node + if (nextNode.Id == loopbackTargetId) + { + // Add LoopBodyEnd statement to return to loop + block.Statements.Add(new FlowControlStatement + { + ControlType = FlowControlType.LoopBodyEnd, + LoopBodyEndReturnTo = loopbackTargetId + }); + return; // End of loop body + } + + CollectNodesRecursive(blueprint, nextNode, block, context, visited, processedTargets, loopbackTargetId); + } + } + } + } + } + + private BlockStatement? ConvertNodeToStatement(BlueprintNode node, Contract.Workflow.Blueprint blueprint) + { + switch (node) + { + case PrintNode print: + var printValue = GetInputValue(print, "Value", blueprint); + return new ExpressionStatement + { + Expression = $"Print({printValue});", + SourceCode = $"Print({printValue});", + LineNumber = 1 + }; + + case PauseNode pause: + var ms = GetInputValue(pause, "Milliseconds", blueprint); + return new ExpressionStatement + { + Expression = $"Pause({ms});", + SourceCode = $"Pause({ms});", + LineNumber = 1 + }; + + case CallNode call: + var callArgs = GetInputArgs(call, blueprint); + return new ExpressionStatement + { + Expression = $"{call.FunctionName}({callArgs});", + SourceCode = $"{call.FunctionName}({callArgs});", + LineNumber = 1 + }; + + case CallHelperNode callHelper: + var helperArgs = GetInputArgs(callHelper, blueprint); + return new ExpressionStatement + { + Expression = $"{callHelper.HelperFunctionName}({helperArgs});", + SourceCode = $"{callHelper.HelperFunctionName}({helperArgs});", + LineNumber = 1 + }; + + case BreakNode: + return new FlowControlStatement + { + ControlType = FlowControlType.Break, + SourceCode = "Break();", + LineNumber = 1 + }; + + case BranchNode branch: + var branchFlow = new FlowControlStatement + { + ControlType = FlowControlType.Branch, + ConditionExpression = GetConditionExpression(branch, blueprint), + SourceCode = $"Branch({GetConditionExpression(branch, blueprint)}, \"\", \"\");", + LineNumber = 1 + }; + return branchFlow; + + case LoopNode loop: + var loopFlow = new FlowControlStatement + { + ControlType = FlowControlType.Loop, + ConditionExpression = GetConditionExpression(loop, blueprint), + SourceCode = $"Loop({GetConditionExpression(loop, blueprint)}, \"\", \"\");", + LineNumber = 1 + }; + return loopFlow; + + default: + return null; + } + } + + private string GetConditionExpression(BlueprintNode node, Contract.Workflow.Blueprint blueprint) + { + var condPin = node.InputPins.FirstOrDefault(p => p.Name == "Condition"); + if (condPin == null) return string.Empty; + + // Find the data connection to the Condition pin + var dataConn = blueprint.Connections + .FirstOrDefault(c => c.TargetPinId == condPin.Id); + + if (dataConn == null) + return condPin.DefaultValue ?? string.Empty; + + var sourceNode = blueprint.GetNodeById(dataConn.SourceNodeId); + if (sourceNode is ConstNode constNode) + return constNode.ConstName; + + // For other nodes, return the PubVar name if available + return dataConn.PubVarName ?? condPin.DefaultValue ?? string.Empty; + } + + private string GetInputValue(BlueprintNode node, string pinName, Contract.Workflow.Blueprint blueprint) + { + var pin = node.InputPins.FirstOrDefault(p => p.Name == pinName); + if (pin == null) return string.Empty; + + // Find the data connection to this pin + var dataConn = blueprint.Connections.FirstOrDefault(c => c.TargetPinId == pin.Id); + if (dataConn == null) return pin.DefaultValue ?? string.Empty; + + var sourceNode = blueprint.GetNodeById(dataConn.SourceNodeId); + if (sourceNode is ConstNode constNode) + return constNode.ConstName; + + // For other nodes, return the PubVar name if available + return dataConn.PubVarName ?? pin.DefaultValue ?? string.Empty; + } + + private string GetInputArgs(BlueprintNode node, Contract.Workflow.Blueprint blueprint) + { + var args = new List(); + foreach (var pin in node.InputPins) + { + if (pin.Name != "Exec") + { + args.Add(GetInputValue(node, pin.Name, blueprint)); + } + } + return string.Join(", ", args); + } + + private BlockDefinition GenerateBlock(ReverseConversionContext context, + List nodes, string blockName) + { + var block = new BlockDefinition + { + Type = blockName == "MainBlock" ? BlockType.MainBlock : BlockType.NamedBlock, + Name = blockName + }; + + foreach (var node in nodes) + { + var statement = ConvertNodeToStatement(node, context.Blueprint); + if (statement != null) + { + block.Statements.Add(statement); + + // Track FlowControlStatements for later setting target block names + if (statement is FlowControlStatement flow) + { + context.ControlFlowMap[node.Id] = flow; + } + } + } + + return block; + } + + private bool IsExecConnection(Contract.Workflow.Blueprint blueprint, BlueprintConnection conn) + { + var sourceNode = blueprint.GetNodeById(conn.SourceNodeId); + var sourcePin = sourceNode?.OutputPins.FirstOrDefault(p => p.Id == conn.SourcePinId); + return sourcePin?.Name == "Exec"; + } + + private void GenerateSourceCode(BlockScript script) + { + var sb = new StringBuilder(); + + // #ConstBlock + if (script.ConstBlock != null && script.ConstBlock.Variables.Count > 0) + { + sb.AppendLine("#ConstBlock"); + foreach (var variable in script.ConstBlock.Variables) + { + sb.AppendLine($"const {variable.Type} {variable.Name} = {variable.DefaultValue};"); + } + sb.AppendLine(); + } + + // #PubVarBlock + if (script.PubVarBlock != null && script.PubVarBlock.Variables.Count > 0) + { + sb.AppendLine("#PubVarBlock"); + foreach (var variable in script.PubVarBlock.Variables) + { + sb.AppendLine($"{variable.Type} {variable.Name};"); + } + sb.AppendLine(); + } + + // #MainBlock + if (script.MainBlock != null) + { + sb.AppendLine("#MainBlock"); + foreach (var statement in script.MainBlock.Statements) + { + sb.AppendLine(statement.SourceCode); + } + sb.AppendLine(); + } + + // NamedBlocks + foreach (var kvp in script.NamedBlocks) + { + sb.AppendLine($"#Block {kvp.Key}"); + foreach (var statement in kvp.Value.Statements) + { + sb.AppendLine(statement.SourceCode); + } + sb.AppendLine(); + } + + script.SourceCode = sb.ToString(); + } + + private class ReverseConversionContext + { + public required Contract.Workflow.Blueprint Blueprint { get; set; } + public required BlockScript Script { get; set; } + public int PubVarIndex { get; set; } + public int BlockIndex { get; set; } + + /// + /// Maps node ID to its FlowControlStatement for setting target block names later + /// + public Dictionary ControlFlowMap { get; set; } = new(); + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeTemplateProvider.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeTemplateProvider.cs new file mode 100644 index 00000000..495ce9f4 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeTemplateProvider.cs @@ -0,0 +1,208 @@ +using System; +using System.Collections.Generic; +using KitX.Core.Contract.Workflow; + +namespace KitX.Core.Workflow.Blueprint; + +/// +/// 节点模板提供者 - 统一所有节点的尺寸和引脚位置 +/// +public class NodeTemplateProvider : INodeTemplateProvider +{ + private readonly Dictionary _templates; + + public NodeTemplateProvider() + { + _templates = new Dictionary + { + [BlueprintNodeType.Entry] = new NodeTemplate + { + NodeType = BlueprintNodeType.Entry, + Name = "Entry", + Width = 120, + Height = 60, + OutputPins = + [ + new PinTemplate { Name = "Exec", Type = PinType.Execution, RelativeY = 30 } + ] + }, + + [BlueprintNodeType.Branch] = new NodeTemplate + { + NodeType = BlueprintNodeType.Branch, + Name = "Branch", + Width = 120, + Height = 80, + InputPins = + [ + new PinTemplate { Name = "Exec", Type = PinType.Execution, RelativeY = 30 }, + new PinTemplate { Name = "Condition", Type = PinType.Boolean, RelativeY = 50 } + ], + OutputPins = + [ + new PinTemplate { Name = "True", Type = PinType.Execution, RelativeY = 30 }, + new PinTemplate { Name = "False", Type = PinType.Execution, RelativeY = 50 } + ] + }, + + [BlueprintNodeType.Loop] = new NodeTemplate + { + NodeType = BlueprintNodeType.Loop, + Name = "Loop", + Width = 120, + Height = 80, + InputPins = + [ + new PinTemplate { Name = "Exec", Type = PinType.Execution, RelativeY = 30 }, + new PinTemplate { Name = "Condition", Type = PinType.Boolean, RelativeY = 50 } + ], + OutputPins = + [ + new PinTemplate { Name = "LoopBody", Type = PinType.Execution, RelativeY = 30 }, + new PinTemplate { Name = "LoopEnd", Type = PinType.Execution, RelativeY = 50 } + ] + }, + + [BlueprintNodeType.Break] = new NodeTemplate + { + NodeType = BlueprintNodeType.Break, + Name = "Break", + Width = 100, + Height = 40, + InputPins = + [ + new PinTemplate { Name = "Exec", Type = PinType.Execution, RelativeY = 20 } + ] + }, + + [BlueprintNodeType.Const] = new NodeTemplate + { + NodeType = BlueprintNodeType.Const, + Name = "Const", + Width = 120, + Height = 50, + OutputPins = + [ + new PinTemplate { Name = "Value", Type = PinType.Any, RelativeY = 25 } + ] + }, + + [BlueprintNodeType.Call] = new NodeTemplate + { + NodeType = BlueprintNodeType.Call, + Name = "Call", + Width = 140, + Height = 60, + InputPins = + [ + new PinTemplate { Name = "Exec", Type = PinType.Execution, RelativeY = 20 } + ], + OutputPins = + [ + new PinTemplate { Name = "Exec", Type = PinType.Execution, RelativeY = 20 }, + new PinTemplate { Name = "Return", Type = PinType.Any, RelativeY = 40 } + ] + }, + + [BlueprintNodeType.CallHelper] = new NodeTemplate + { + NodeType = BlueprintNodeType.CallHelper, + Name = "CallHelper", + Width = 130, + Height = 50, + InputPins = + [ + new PinTemplate { Name = "Exec", Type = PinType.Execution, RelativeY = 25 } + ], + OutputPins = + [ + new PinTemplate { Name = "Exec", Type = PinType.Execution, RelativeY = 25 }, + new PinTemplate { Name = "Return", Type = PinType.Any, RelativeY = 40 } + ] + }, + + [BlueprintNodeType.Print] = new NodeTemplate + { + NodeType = BlueprintNodeType.Print, + Name = "Print", + Width = 100, + Height = 50, + InputPins = + [ + new PinTemplate { Name = "Exec", Type = PinType.Execution, RelativeY = 20 }, + new PinTemplate { Name = "Value", Type = PinType.Any, RelativeY = 35 } + ], + OutputPins = + [ + new PinTemplate { Name = "Exec", Type = PinType.Execution, RelativeY = 25 } + ] + }, + + [BlueprintNodeType.Pause] = new NodeTemplate + { + NodeType = BlueprintNodeType.Pause, + Name = "Pause", + Width = 100, + Height = 50, + InputPins = + [ + new PinTemplate { Name = "Exec", Type = PinType.Execution, RelativeY = 20 }, + new PinTemplate { Name = "Milliseconds", Type = PinType.Integer, RelativeY = 35 } + ], + OutputPins = + [ + new PinTemplate { Name = "Exec", Type = PinType.Execution, RelativeY = 25 } + ] + } + }; + } + + public BlueprintNode CreateNode(BlueprintNodeType type) => type switch + { + BlueprintNodeType.Entry => new EntryNode(), + BlueprintNodeType.Branch => new BranchNode(), + BlueprintNodeType.Loop => new LoopNode(), + BlueprintNodeType.Break => new BreakNode(), + BlueprintNodeType.Const => new ConstNode(), + BlueprintNodeType.Call => new CallNode(), + BlueprintNodeType.CallHelper => new CallHelperNode(), + BlueprintNodeType.Print => new PrintNode(), + BlueprintNodeType.Pause => new PauseNode(), + _ => throw new ArgumentException($"Unknown node type: {type}") + }; + + public IReadOnlyDictionary GetTemplates() => _templates; + + public (double Width, double Height) GetNodeSize(BlueprintNodeType type) + { + if (_templates.TryGetValue(type, out var template)) + return (template.Width, template.Height); + return (120, 60); + } + + public double GetInputPinY(BlueprintNodeType nodeType, string pinName) + { + if (_templates.TryGetValue(nodeType, out var template)) + { + foreach (var pin in template.InputPins) + { + if (pin.Name == pinName) + return pin.RelativeY; + } + } + return 30; + } + + public double GetOutputPinY(BlueprintNodeType nodeType, string pinName) + { + if (_templates.TryGetValue(nodeType, out var template)) + { + foreach (var pin in template.OutputPins) + { + if (pin.Name == pinName) + return pin.RelativeY; + } + } + return 30; + } +} diff --git a/KitX Clients/KitX Dashboard b/KitX Clients/KitX Dashboard index a141fee7..64049634 160000 --- a/KitX Clients/KitX Dashboard +++ b/KitX Clients/KitX Dashboard @@ -1 +1 @@ -Subproject commit a141fee7b9e0b54429887ab944e5520d4ab5acda +Subproject commit 64049634edd8b5a629a8a246a1e13925ca4ee684 diff --git a/KitX Standard b/KitX Standard index 314b1841..691241f1 160000 --- a/KitX Standard +++ b/KitX Standard @@ -1 +1 @@ -Subproject commit 314b184152ba065c18da81985a37b738a5eb06eb +Subproject commit 691241f1572d4bd780772a8b637eae1a60a49fb5 diff --git a/KitX.sln b/KitX.sln index 4eca371f..5480b224 100644 --- a/KitX.sln +++ b/KitX.sln @@ -155,6 +155,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KitX.Core", "KitX Clients\K EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KitX.Core.DI.Tests", "KitX Clients\KitX Core\KitX.Core.DI.Tests\KitX.Core.DI.Tests.csproj", "{9D8CEB24-748C-0392-629C-45342937C32A}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KitX.Core.BluePrint.Test", "KitX Clients\KitX Core\KitX.Core.BluePrint.Test\KitX.Core.BluePrint.Test.csproj", "{3C461B17-B9A4-97E1-9A62-79BC972F7A13}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -909,6 +911,26 @@ Global {9D8CEB24-748C-0392-629C-45342937C32A}.Release|x64.Build.0 = Release|Any CPU {9D8CEB24-748C-0392-629C-45342937C32A}.Release|x86.ActiveCfg = Release|Any CPU {9D8CEB24-748C-0392-629C-45342937C32A}.Release|x86.Build.0 = Release|Any CPU + {3C461B17-B9A4-97E1-9A62-79BC972F7A13}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3C461B17-B9A4-97E1-9A62-79BC972F7A13}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3C461B17-B9A4-97E1-9A62-79BC972F7A13}.Debug|ARM.ActiveCfg = Debug|Any CPU + {3C461B17-B9A4-97E1-9A62-79BC972F7A13}.Debug|ARM.Build.0 = Debug|Any CPU + {3C461B17-B9A4-97E1-9A62-79BC972F7A13}.Debug|ARM64.ActiveCfg = Debug|Any CPU + {3C461B17-B9A4-97E1-9A62-79BC972F7A13}.Debug|ARM64.Build.0 = Debug|Any CPU + {3C461B17-B9A4-97E1-9A62-79BC972F7A13}.Debug|x64.ActiveCfg = Debug|Any CPU + {3C461B17-B9A4-97E1-9A62-79BC972F7A13}.Debug|x64.Build.0 = Debug|Any CPU + {3C461B17-B9A4-97E1-9A62-79BC972F7A13}.Debug|x86.ActiveCfg = Debug|Any CPU + {3C461B17-B9A4-97E1-9A62-79BC972F7A13}.Debug|x86.Build.0 = Debug|Any CPU + {3C461B17-B9A4-97E1-9A62-79BC972F7A13}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3C461B17-B9A4-97E1-9A62-79BC972F7A13}.Release|Any CPU.Build.0 = Release|Any CPU + {3C461B17-B9A4-97E1-9A62-79BC972F7A13}.Release|ARM.ActiveCfg = Release|Any CPU + {3C461B17-B9A4-97E1-9A62-79BC972F7A13}.Release|ARM.Build.0 = Release|Any CPU + {3C461B17-B9A4-97E1-9A62-79BC972F7A13}.Release|ARM64.ActiveCfg = Release|Any CPU + {3C461B17-B9A4-97E1-9A62-79BC972F7A13}.Release|ARM64.Build.0 = Release|Any CPU + {3C461B17-B9A4-97E1-9A62-79BC972F7A13}.Release|x64.ActiveCfg = Release|Any CPU + {3C461B17-B9A4-97E1-9A62-79BC972F7A13}.Release|x64.Build.0 = Release|Any CPU + {3C461B17-B9A4-97E1-9A62-79BC972F7A13}.Release|x86.ActiveCfg = Release|Any CPU + {3C461B17-B9A4-97E1-9A62-79BC972F7A13}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -962,6 +984,7 @@ Global {C4C2D1CC-294B-4C93-8A64-34D5212AD74E} = {673CF32C-65BF-4EB3-83D3-47FEC77B47A0} {641562BA-D2B5-4799-8712-11C23912A79B} = {C4C2D1CC-294B-4C93-8A64-34D5212AD74E} {9D8CEB24-748C-0392-629C-45342937C32A} = {C4C2D1CC-294B-4C93-8A64-34D5212AD74E} + {3C461B17-B9A4-97E1-9A62-79BC972F7A13} = {C4C2D1CC-294B-4C93-8A64-34D5212AD74E} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {09BBC227-F41B-4D10-9E38-0EEE07ED17BC} From 98afb81b4ab110ddcc14fed20ec4f2ee798c89c5 Mon Sep 17 00:00:00 2001 From: StarInk Date: Wed, 1 Apr 2026 05:44:58 +0200 Subject: [PATCH 026/110] =?UTF-8?q?=F0=9F=92=BE=20Feat(Workflow):=20Implem?= =?UTF-8?q?ent=20Blueprint=20service=20and=20conversion=20logic-part=20of-?= =?UTF-8?q?2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../KitX.Core.BluePrint.Test/Program.cs | 128 +- .../DI/CoreServiceCollectionExtensions.cs | 7 + .../BlockScriptExecutionGlobals.cs | 13 +- .../BlockScripting/BlockScriptExecutor.cs | 16 + .../BlockScripting/BlockScriptParser.cs | 52 +- .../BlockScriptToBlueprintConverter.cs | 1583 +-------------- .../BlockScriptToBlueprintConverter.cs.bak | 1714 +++++++++++++++++ .../Workflow/Blueprint/BlueprintService.cs | 20 +- .../Blueprint/ConnectionCreationService.cs | 399 ++++ .../Workflow/Blueprint/ConversionContext.cs | 1 + .../Blueprint/FlowProcessingService.cs | 953 +++++++++ .../Blueprint/IConnectionCreationService.cs | 36 + .../Blueprint/IFlowProcessingService.cs | 34 + .../Workflow/Blueprint/LayoutService.cs | 106 + .../Workflow/Blueprint/NodeCreationService.cs | 162 ++ .../Blueprint/NodeTemplateProvider.cs | 36 + KitX Clients/KitX Dashboard | 2 +- KitX Standard | 2 +- 18 files changed, 3637 insertions(+), 1627 deletions(-) create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlockScriptToBlueprintConverter.cs.bak create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ConnectionCreationService.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ConversionContext.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/FlowProcessingService.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/IConnectionCreationService.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/IFlowProcessingService.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/LayoutService.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeCreationService.cs diff --git a/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs b/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs index d051bd97..56c43a15 100644 --- a/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs +++ b/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using Microsoft.Extensions.DependencyInjection; @@ -29,10 +29,18 @@ public static void Main(string[] args) Console.WriteLine($"✓ IBlockScriptParser resolved: {parser.GetType().Name}\n"); // ============================================================ - // Step 2: Create BlockScriptToBlueprintConverter + // Step 2: Create Services for Conversion // ============================================================ + var connectionCreationService = new ConnectionCreationService(); + var flowProcessingService = new FlowProcessingService(connectionCreationService); + var layoutService = new LayoutService(); + // Note: IBlockScriptToBlueprintConverter is NOT in DI, manually instantiate - var converter = new BlockScriptToBlueprintConverter(parser); + var converter = new BlockScriptToBlueprintConverter( + parser, + flowProcessingService, + connectionCreationService, + layoutService); // ============================================================ // Step 3: Sample BlockScript Source Code @@ -243,7 +251,7 @@ public static void Main(string[] args) var tgt = blueprint.GetNodeById(conn.TargetNodeId); var sp = src?.OutputPins.FirstOrDefault(p => p.Id == conn.SourcePinId); var tp = tgt?.InputPins.FirstOrDefault(p => p.Id == conn.TargetPinId); - Console.WriteLine($" • {src?.Name}.{sp?.Name} -> {tgt?.Name}.{tp?.Name}"); + Console.WriteLine($" • {src?.Name}[{src?.Id.Substring(0,8)}].{sp?.Name} -> {tgt?.Name}[{tgt?.Id.Substring(0,8)}].{tp?.Name}"); } Console.WriteLine($"\n Data Connections: {dataConnections.Count}"); @@ -254,7 +262,7 @@ public static void Main(string[] args) var sp = src?.OutputPins.FirstOrDefault(p => p.Id == conn.SourcePinId); var tp = tgt?.InputPins.FirstOrDefault(p => p.Id == conn.TargetPinId); var pubvar = conn.PubVarName != null ? $" (PubVar: {conn.PubVarName})" : ""; - Console.WriteLine($" • {src?.Name}.{sp?.Name} -> {tgt?.Name}.{tp?.Name}{pubvar}"); + Console.WriteLine($" • {src?.Name}[{src?.Id.Substring(0,8)}].{sp?.Name} -> {tgt?.Name}[{tgt?.Id.Substring(0,8)}].{tp?.Name}{pubvar}"); } Console.WriteLine("\n═════════════════════════════════════════════════════════"); @@ -264,56 +272,64 @@ public static void Main(string[] args) private static string GetSampleBlockScript() { - var sb = new System.Text.StringBuilder(); - sb.AppendLine("#ConstBlock"); - sb.AppendLine("int guessNum = 5;"); - sb.AppendLine("int loopMax = 3;"); - sb.AppendLine("int targetNum = 7;"); - sb.AppendLine("int currentLoop;"); - sb.AppendLine(); - sb.AppendLine("#PubVarBlock"); - sb.AppendLine("var vaaa0001;"); - sb.AppendLine("var vaaa0002;"); - sb.AppendLine(); - sb.AppendLine("#MainBlock"); - sb.AppendLine("Print(\"开始执行工作流\");"); - sb.AppendLine("Set(currentLoop, 0);"); - sb.AppendLine("vaaa0001 = HelperFuncCompare(\"BLE\", Get(currentLoop), loopMax);"); - sb.AppendLine("NextBlock = Loop(vaaa0001, \"LoopBody\", \"EndLogic\");"); - sb.AppendLine(); - sb.AppendLine("#Block LoopBody"); - sb.AppendLine("vaaa0002 = Get(currentLoop);"); - sb.AppendLine("Print(vaaa0002);"); - sb.AppendLine("Set(currentLoop, HelperFuncAdd(Get(currentLoop), 1));"); - sb.AppendLine("NextBlock = Branch("); - sb.AppendLine(" HelperFuncCompare(\"BEQ\", guessNum, targetNum),"); - sb.AppendLine(" \"SuccessLogic\","); - sb.AppendLine(" \"CheckLogic\""); - sb.AppendLine(");"); - sb.AppendLine(); - sb.AppendLine("#Block CheckLogic"); - sb.AppendLine("NextBlock = Branch("); - sb.AppendLine(" HelperFuncCompare(\"BLT\", guessNum, targetNum),"); - sb.AppendLine(" \"LessThanLogic\","); - sb.AppendLine(" \"GreaterThanLogic\""); - sb.AppendLine(");"); - sb.AppendLine(); - sb.AppendLine("#Block LessThanLogic"); - sb.AppendLine("Print(\"猜小了\");"); - sb.AppendLine("vaaa0001 = HelperFuncCompare(\"BLE\", Get(currentLoop), loopMax);"); - sb.AppendLine("NextBlock = LoopBodyEnd(\"MainBlock\");"); - sb.AppendLine(); - sb.AppendLine("#Block GreaterThanLogic"); - sb.AppendLine("Print(\"猜大了\");"); - sb.AppendLine("vaaa0001 = HelperFuncCompare(\"BLE\", Get(currentLoop), loopMax);"); - sb.AppendLine("NextBlock = LoopBodyEnd(\"MainBlock\");"); - sb.AppendLine(); - sb.AppendLine("#Block SuccessLogic"); - sb.AppendLine("Print(\"猜对啦!\");"); - sb.AppendLine(); - sb.AppendLine("#Block EndLogic"); - sb.AppendLine("Print(\"示例工作流结束\");"); - - return sb.ToString(); + string sourceCode = @"#ConstBlock +int guessNum = 5; // 可变常量,用户在UI中可修改 +int loopMax = 3; +int targetNum = 7; +int currentLoop; // 无预赋值(值初始化),用户无法在UI中修改 +// 这个currentLoop为什么不能放在PubVarBlock中: +// 它不是“一次性”的“边数据承载”变量,它是多处、多次使用且随运行而需要变化并持久存储的变量,它的最短生命周期远长于PubVarBlock中的一次性变量(赋值-使用1次后即可销毁,下次用到再重新创建) + +#PubVarBlock +// 自动生成,为蓝图预留(是那些数据边为了临时承载数据而使用的变量) +// 除非你知道自己在做什么并且完全了解块脚本与蓝图互译的过程,否则不要在这个块中添加、删除或修改代码 +// 直接编写BlockScript时不需要在这里设置变量 +bool vaaa0001; +int vaaa0002; + +#MainBlock +Print(""开始执行工作流""); +Set(""currentLoop"", 0); +// NextBlock = Loop(HelperFuncCompare(""BLE"", Get(""currentLoop""), loopMax), ""LoopBody"", ""EndLogic""); // 转化前的语句(有嵌套调用) +vaaa0001 = HelperFuncCompare(""BLE"", Get(""currentLoop""), loopMax); // 脚本转蓝图后,再转回块脚本时,就会利用PubVarBlock中生成的“临时变量”来生成这样的语句(拆分嵌套调用) +NextBlock = Loop(vaaa0001, ""LoopBody"", ""EndLogic""); // 主循环 + +#Block LoopBody +// Print(Get(currentLoop)); // 原始嵌套调用用法 +// vaaa0001 = Get(currentLoop); +// Print(vaaa0001); // 其实逻辑上等价但是不推荐的方案──反正PubVarBlock的变量池是“无限大”的,没必要重复使用同一个变量。 +vaaa0002 = Get(""currentLoop""); +Print(vaaa0002); // 更优的做法,每个临时变量实际上绑定了一条数据边。这样也方便后续直接对蓝图脚本进行Debug时监测数据边上的数据 +Set(""currentLoop"", HelperFuncAdd(Get(""currentLoop""), 1)); // 函数嵌套调用,记得在默认的HelperFunction初始化程序中添加这个HelperFuncAdd +NextBlock = Branch( + HelperFuncCompare(""BEQ"", guessNum, targetNum), // ✅ 函数调用 + ""SuccessLogic"", + ""CheckLogic"" +); + +#Block CheckLogic +NextBlock = Branch( + HelperFuncCompare(""BLT"", guessNum, targetNum), + ""LessThanLogic"", + ""GreaterThanLogic"" +); + +#Block LessThanLogic +Print(""猜小了""); // 其实用Print()也行 +vaaa0001 = HelperFuncCompare(""BLE"", Get(""currentLoop""), loopMax); // 原先Loop中的内置嵌套condition表达式由于被拆分,需要在LoopBodyEnd被调用前进行结算,以保持逻辑一致性 +NextBlock = LoopBodyEnd(""MainBlock""); // 返回到 MainBlock 的 Loop + +#Block GreaterThanLogic +Print(""猜大了""); +vaaa0001 = HelperFuncCompare(""BLE"", Get(""currentLoop""), loopMax); // 原先Loop中的内置嵌套condition表达式由于被拆分,需要在LoopBodyEnd被调用前进行结算,以保持逻辑一致性 +// 这里仍是vaaa0001是因为在蓝图中实际上是一条边:CallHelper:HelperFuncCompare(BLE).Return --> Loop.Condition | PubVar=vaaa0001 +NextBlock = LoopBodyEnd(""MainBlock""); // 返回到 MainBlock 的 Loop + +#Block SuccessLogic +Print(""猜对啦!""); // 这个Block没有Branch/Loop/LoopBodyEnd,自然进入下一行 + +#Block EndLogic +Print(""示例工作流结束"");"; + return sourceCode; } } diff --git a/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs b/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs index 7bd257b7..8a6bdd27 100644 --- a/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs +++ b/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs @@ -197,6 +197,13 @@ public static IServiceCollection AddCoreServices(this IServiceCollection service return service; }); + // Blueprint Sub-services (must be registered before IBlueprintService) + Log.Information("Registering Blueprint sub-services..."); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + // INodeCreationService: no implementation class yet, to be added later + // Blueprint Services Log.Information("Registering IBlueprintService..."); services.AddSingleton(); diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutionGlobals.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutionGlobals.cs index d53522d6..3888ddcb 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutionGlobals.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutionGlobals.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Threading; using KitX.Core.Workflow; @@ -31,20 +31,21 @@ public BlockScriptExecutionGlobals(BlockScopeManager scopeManager, List /// /// Gets a variable value - called by CSharpScript when accessing unknown properties + /// Returns dynamic to allow implicit conversion to target variable types /// - public object? GetVariable(string name) + public dynamic Get(string name) { if (name == "NextBlock") - return NextBlock; + return NextBlock!; if (_variables.TryGetValue(name, out var value)) - return value; - return _scopeManager.ResolveVariable(name); + return value!; + return _scopeManager.ResolveVariable(name)!; } /// /// Sets a variable value /// - public void SetVariable(string name, object? value) + public void Set(string name, object? value) { if (name == "NextBlock") { diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutor.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutor.cs index 170694c9..ad38004e 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutor.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutor.cs @@ -630,6 +630,12 @@ private string BuildInitializationScript(BlockScript script) initCode.Append(BuildHelperFunctionsCode(script.HelperFunctions)); // Add variable declarations from ConstBlock and PubVarBlock + // IMPORTANT: All variables must be declared in CSharpScript, even without initial values + // + // Key distinction: + // - With DefaultValue: use "var name = value;" (C# can infer the type from the literal value) + // - Without DefaultValue: use explicit type "type name;" (C# requires explicit type for uninitialized vars) + // Note: "var name;" is INVALID in C# because implicitly-typed variables must be initialized if (script.ConstBlock != null) { foreach (var variable in script.ConstBlock.Variables) @@ -638,6 +644,11 @@ private string BuildInitializationScript(BlockScript script) { initCode.AppendLine($"var {variable.Name} = {variable.DefaultValue};"); } + else + { + // Use explicit type since there's no initializer + initCode.AppendLine($"{variable.Type} {variable.Name};"); + } } } @@ -649,6 +660,11 @@ private string BuildInitializationScript(BlockScript script) { initCode.AppendLine($"var {variable.Name} = {variable.DefaultValue};"); } + else + { + // Use explicit type since there's no initializer + initCode.AppendLine($"{variable.Type} {variable.Name};"); + } } } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptParser.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptParser.cs index 37d97718..847c051b 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptParser.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptParser.cs @@ -381,8 +381,18 @@ private void ValidateBlockSpecificRules(RecognizedBlock recognized, SyntaxNode r switch (recognized.BlockType) { case BlockType.ConstBlock: + // ConstBlock: only variable declarations allowed, initializers are OK (const values) + if (stmt is not LocalDeclarationStatementSyntax) + { + result.IsValid = false; + result.ErrorMessage = $"[{recognized.BlockType}] Only variable declarations are allowed. Found: {stmt.Kind()}"; + result.ErrorLine = recognized.StartLine + stmt.GetLineNumber(); + return; + } + break; + case BlockType.PubVarBlock: - // These blocks should only have variable declarations + // PubVarBlock: only variable declarations allowed, but NO initializers if (stmt is not LocalDeclarationStatementSyntax) { result.IsValid = false; @@ -390,12 +400,48 @@ private void ValidateBlockSpecificRules(RecognizedBlock recognized, SyntaxNode r result.ErrorLine = recognized.StartLine + stmt.GetLineNumber(); return; } + + // PubVarBlock declarations must NOT have initializers + if (stmt is LocalDeclarationStatementSyntax varDecl) + { + foreach (var variable in varDecl.Declaration.Variables) + { + if (variable.Initializer != null) + { + result.IsValid = false; + result.ErrorMessage = $"[{recognized.BlockType}] PubVarBlock variable declarations cannot have initializers. Variable '{variable.Identifier.Text}' has an initializer."; + result.ErrorLine = recognized.StartLine + stmt.GetLineNumber(); + return; + } + } + } + + // PubVarBlock must not contain assignment operations (check for AssignmentExpressionSyntax in any expression statement) + if (stmt is ExpressionStatementSyntax exprStmt) + { + var hasAssignment = exprStmt.DescendantNodes().Any(n => n is AssignmentExpressionSyntax); + if (hasAssignment) + { + result.IsValid = false; + result.ErrorMessage = $"[{recognized.BlockType}] PubVarBlock cannot contain assignment operations."; + result.ErrorLine = recognized.StartLine + stmt.GetLineNumber(); + return; + } + } break; case BlockType.MainBlock: case BlockType.NamedBlock: - // These blocks can have any valid C# statement - // But we should check for prohibited constructs like if/else/for/while + // MainBlock and NamedBlock: no variable declarations allowed + if (stmt is LocalDeclarationStatementSyntax) + { + result.IsValid = false; + result.ErrorMessage = $"[{recognized.BlockType}] Variable declarations are not allowed. Use ConstBlock or PubVarBlock instead."; + result.ErrorLine = recognized.StartLine + stmt.GetLineNumber(); + return; + } + + // Check for prohibited syntax (if/else/for/while/try/catch) if (ContainsProhibitedSyntax(stmt)) { result.IsValid = false; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlockScriptToBlueprintConverter.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlockScriptToBlueprintConverter.cs index 1e425407..f966dad9 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlockScriptToBlueprintConverter.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlockScriptToBlueprintConverter.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Text; using System.Threading; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; @@ -17,10 +16,20 @@ namespace KitX.Core.Workflow.Blueprint; public class BlockScriptToBlueprintConverter : IBlockScriptToBlueprintConverter { private readonly IBlockScriptParser _parser; + private readonly IFlowProcessingService _flowProcessingService; + private readonly IConnectionCreationService _connectionCreationService; + private readonly ILayoutService _layoutService; - public BlockScriptToBlueprintConverter(IBlockScriptParser parser) + public BlockScriptToBlueprintConverter( + IBlockScriptParser parser, + IFlowProcessingService flowProcessingService, + IConnectionCreationService connectionCreationService, + ILayoutService layoutService) { _parser = parser; + _flowProcessingService = flowProcessingService; + _connectionCreationService = connectionCreationService; + _layoutService = layoutService; } public Contract.Workflow.Blueprint Convert(string sourceCode, List? helperFunctions = null) @@ -47,28 +56,34 @@ public Contract.Workflow.Blueprint Convert(BlockScript script) Blueprint = blueprint, Script = script, NodeMap = new Dictionary(), - NextPubVarIndex = 0 + NextPubVarIndex = 0, + NamedBlockMap = new Dictionary(), + BlockFirstNodes = new Dictionary(), + VisitedBlocks = new HashSet(), + VariableSources = new Dictionary(), + LoopNodesByParentBlock = new Dictionary(), + PendingNodesForExecChain = new List() }; // Process ConstBlock if (script.ConstBlock != null) { - ProcessConstBlock(script.ConstBlock, context); + _flowProcessingService.ProcessConstBlock(script.ConstBlock, context); } // Process PubVarBlock if (script.PubVarBlock != null) { - ProcessPubVarBlock(script.PubVarBlock, context); + _flowProcessingService.ProcessPubVarBlock(script.PubVarBlock, context); } - // Process MainBlock - create Entry node and main execution flow + // Process MainBlock if (script.MainBlock != null) { - ProcessMainBlock(script.MainBlock, context); + _flowProcessingService.ProcessMainBlock(script.MainBlock, context, null); } - // Process NamedBlocks (for calls from main flow) + // Process NamedBlocks foreach (var kvp in script.NamedBlocks) { context.NamedBlockMap[kvp.Key] = kvp.Value; @@ -77,1563 +92,15 @@ public Contract.Workflow.Blueprint Convert(BlockScript script) // Process LoopBlocks foreach (var kvp in script.LoopBlocks) { - ProcessLoopBlock(kvp.Value, context); + _flowProcessingService.ProcessLoopBlock(kvp.Value, context, null); } // Auto-layout nodes - LayoutNodes(blueprint); + _layoutService.LayoutNodes(blueprint); Log.Information("[BlueprintConversion] Complete: {NodeCount} nodes, {ConnectionCount} connections", blueprint.Nodes.Count, blueprint.Connections.Count); return blueprint; } - - private void ProcessConstBlock(BlockDefinition block, ConversionContext context) - { - foreach (var variable in block.Variables) - { - // ✅ 如果 ConstValue 为空,根据类型设置默认值 - var constValue = variable.DefaultValue?.ToString(); - if (string.IsNullOrEmpty(constValue)) - { - constValue = variable.Type.ToLower() switch - { - "int" or "integer" or "long" or "short" or "byte" => "0", - "float" or "double" or "decimal" => "0", - "bool" or "boolean" => "false", - "string" => "", - "char" => "\0", - _ => "" - }; - } - - var constNode = new ConstNode - { - ConstName = variable.Name, - ConstType = variable.Type, - ConstValue = constValue - }; - context.Blueprint.AddNode(constNode); - context.Blueprint.ConstValues.Add(new VariableConstant - { - Name = variable.Name, - Type = variable.Type, - DefaultValue = variable.DefaultValue - }); - Log.Debug("Created ConstNode: Name={ConstName}, Id={NodeId}", constNode.Name, constNode.Id); - } - } - - private void ProcessPubVarBlock(BlockDefinition block, ConversionContext context) - { - foreach (var variable in block.Variables) - { - context.Blueprint.PubVarNames.Add(variable.Name); - } - } - - private void ProcessMainBlock(BlockDefinition block, ConversionContext context) - { - // Create Entry node - var entryNode = new EntryNode - { - X = 50, - Y = 50 - }; - context.Blueprint.AddNode(entryNode); - context.EntryNode = entryNode; - Log.Debug("Created EntryNode: Name={NodeName}, Id={NodeId}", entryNode.Name, entryNode.Id); - - // Process all statements in main block - var mainFlowNodes = new List(); - var currentY = 150.0; - - Console.WriteLine($"[DEBUG] ProcessMainBlock: Starting foreach, Statements.Count = {block.Statements.Count}"); - for (int i = 0; i < block.Statements.Count; i++) - { - var statement = block.Statements[i]; - var sourcePreview = statement.SourceCode?.Length > 50 - ? statement.SourceCode.Substring(0, 50) + "..." - : statement.SourceCode ?? "(null)"; - Console.WriteLine($"[DEBUG] ProcessMainBlock: Processing statement {i}: {statement.GetType().Name}, SourceCode: {sourcePreview}"); - var node = ProcessStatement(statement, context, 50, currentY); - Console.WriteLine($"[DEBUG] ProcessMainBlock: ProcessStatement returned: {node?.Name} ({node?.Id})"); - if (node != null) - { - Console.WriteLine($"[DEBUG] ProcessMainBlock: Adding node {node.Name} ({node.Id}) to blueprint"); - context.Blueprint.AddNode(node); - mainFlowNodes.Add(node); - currentY += 120; - } - } - Console.WriteLine($"[DEBUG] ProcessMainBlock: Finished foreach, mainFlowNodes.Count = {mainFlowNodes.Count}"); - - // Link main flow with Exec connections - LinkNodesWithExec(mainFlowNodes, context.Blueprint); - - Console.WriteLine($"[DEBUG] ProcessMainBlock: mainFlowNodes count = {mainFlowNodes.Count}"); - for (int i = 0; i < mainFlowNodes.Count; i++) - { - Console.WriteLine($"[DEBUG] ProcessMainBlock: mainFlowNodes[{i}] = {mainFlowNodes[i].Name} ({mainFlowNodes[i].Id})"); - } - - // ✅ 处理 Branch/Loop 节点与子块的连接 - // 注意:Loop 节点存储在 LoopBlocks["MainBlock"] 中,不在 MainBlock.Statements - // 因此需要从 context.LoopNodesByParentBlock 中查找 - var flowNode = mainFlowNodes.LastOrDefault(n => n is BranchNode || n is LoopNode); - FlowControlStatement? flowCtrl = null; - - // 如果 mainFlowNodes 中没有找到 Loop,尝试从 LoopBlocks 处理 - if (flowNode == null) - { - // 检查 LoopBlocks 中是否有 MainBlock 的 Loop - if (context.Script.LoopBlocks.TryGetValue("MainBlock", out var loopBlock)) - { - // 获取 Loop FlowControlStatement - flowCtrl = loopBlock.Statements.FirstOrDefault(s => s is FlowControlStatement) as FlowControlStatement; - if (flowCtrl != null) - { - // ✅ 设置 CurrentParentBlock 以便后续 ProcessLoopBlock 复用此 Loop 节点 - context.CurrentParentBlock = "MainBlock"; - - // 在 LoopBlocks["MainBlock"] 中创建 Loop 节点 - var loopNodeInLoopBlock = ProcessFlowControlStatement(flowCtrl, context, 350, 150); - if (loopNodeInLoopBlock is LoopNode loopFromBlock) - { - flowNode = loopFromBlock; - // ✅ 注册到 LoopNodesByParentBlock 以便后续使用 - context.LoopNodesByParentBlock["MainBlock"] = loopFromBlock; - Console.WriteLine($"[DEBUG] ProcessMainBlock: Created Loop node from LoopBlocks, registering as MainBlock's Loop"); - } - } - } - } - else - { - // Branch/Loop 在 MainBlock.Statements 中 - flowCtrl = block.Statements.LastOrDefault(s => s is FlowControlStatement) as FlowControlStatement; - } - - Console.WriteLine($"[DEBUG] ProcessMainBlock: flowNode = {flowNode?.Name} ({flowNode?.Id})"); - if (flowNode != null && flowCtrl != null) - { - Console.WriteLine($"[DEBUG] ProcessMainBlock: Processing flow node connections"); - if (flowNode is BranchNode branch) - { - ProcessBranchNodeConnectionsInMain(branch, flowCtrl, context); - } - else if (flowNode is LoopNode loop) - { - ProcessLoopNodeConnectionsInMain(loop, flowCtrl, context); - } - } - - // Link Entry to first main flow node - if (mainFlowNodes.Count > 0) - { - var firstNode = mainFlowNodes[0]; - var entryExecPin = entryNode.OutputPins.First(p => p.Name == "Exec"); - var firstExecPin = firstNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); - if (entryExecPin != null && firstExecPin != null) - { - context.Blueprint.AddConnection(new BlueprintConnection - { - SourceNodeId = entryNode.Id, - SourcePinId = entryExecPin.Id, - TargetNodeId = firstNode.Id, - TargetPinId = firstExecPin.Id - }); - } - } - } - - private void ProcessBranchNodeConnectionsInMain(BranchNode branch, FlowControlStatement flowCtrl, ConversionContext context) - { - double x = 350; - double targetY = 300; - - // 处理 True 分支 - if (!string.IsNullOrEmpty(flowCtrl.TrueBlockName)) - { - var trueBlock = context.Script.GetBlockByName(flowCtrl.TrueBlockName); - if (trueBlock != null) - { - var firstTrueNode = ProcessBlockRecursive(trueBlock, context, x, targetY); - if (firstTrueNode != null) - { - var truePin = branch.OutputPins.FirstOrDefault(p => p.Name == "True"); - var firstExecIn = firstTrueNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); - if (truePin != null && firstExecIn != null) - { - Console.WriteLine($"[DEBUG] ProcessBranchNodeConnectionsInMain: Creating {branch.Name}.True -> {firstTrueNode.Name}.Exec"); - context.Blueprint.AddConnection(new BlueprintConnection - { - SourceNodeId = branch.Id, - SourcePinId = truePin.Id, - TargetNodeId = firstTrueNode.Id, - TargetPinId = firstExecIn.Id - }); - } - } - } - } - - // 处理 False 分支 - if (!string.IsNullOrEmpty(flowCtrl.FalseBlockName)) - { - var falseBlock = context.Script.GetBlockByName(flowCtrl.FalseBlockName); - if (falseBlock != null) - { - // ✅ 如果 falseBlock 已经访问过,跳过(避免重复创建连接) - if (context.VisitedBlocks.Contains(falseBlock.Name)) - { - Console.WriteLine($"[DEBUG] ProcessBranchNodeConnectionsInMain: falseBlock {falseBlock.Name} already visited, skipping"); - } - else - { - var firstFalseNode = ProcessBlockRecursive(falseBlock, context, x, targetY + 200); - if (firstFalseNode != null && firstFalseNode.Id != branch.Id) - { - var falsePin = branch.OutputPins.FirstOrDefault(p => p.Name == "False"); - var firstExecIn = firstFalseNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); - if (falsePin != null && firstExecIn != null) - { - Console.WriteLine($"[DEBUG] ProcessBranchNodeConnectionsInMain: Creating {branch.Name}.False -> {firstFalseNode.Name}.Exec"); - context.Blueprint.AddConnection(new BlueprintConnection - { - SourceNodeId = branch.Id, - SourcePinId = falsePin.Id, - TargetNodeId = firstFalseNode.Id, - TargetPinId = firstExecIn.Id - }); - } - } - } - } - } - } - - private void ProcessLoopNodeConnectionsInMain(LoopNode loop, FlowControlStatement flowCtrl, ConversionContext context) - { - // ✅ 使用已创建的 loop 节点,不重新创建 - double x = 350; - double targetY = 300; - - // ✅ 处理 LoopBody - if (!string.IsNullOrEmpty(flowCtrl.TrueBlockName)) - { - var loopBodyBlock = context.Script.GetBlockByName(flowCtrl.TrueBlockName); - if (loopBodyBlock != null) - { - // ✅ 同时注册 TrueBlockName 和 ParentBlockName,以便 LoopBodyEnd 查找 - context.LoopNodesByParentBlock[flowCtrl.TrueBlockName] = loop; - if (!string.IsNullOrEmpty(flowCtrl.LoopBodyEndReturnTo)) - { - context.LoopNodesByParentBlock[flowCtrl.LoopBodyEndReturnTo] = loop; - } - - var firstLoopNode = ProcessBlockRecursive(loopBodyBlock, context, x, targetY); - if (firstLoopNode != null) - { - var loopBodyPin = loop.OutputPins.FirstOrDefault(p => p.Name == "LoopBody"); - var firstExecIn = firstLoopNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); - if (loopBodyPin != null && firstExecIn != null) - { - Console.WriteLine($"[DEBUG] ProcessLoopNodeConnectionsInMain: Creating {loop.Name}.LoopBody -> {firstLoopNode.Name}.Exec"); - context.Blueprint.AddConnection(new BlueprintConnection - { - SourceNodeId = loop.Id, - SourcePinId = loopBodyPin.Id, - TargetNodeId = firstLoopNode.Id, - TargetPinId = firstExecIn.Id - }); - } - } - } - } - - // 处理 LoopEnd - if (!string.IsNullOrEmpty(flowCtrl.FalseBlockName)) - { - var afterLoopBlock = context.Script.GetBlockByName(flowCtrl.FalseBlockName); - if (afterLoopBlock != null) - { - var firstAfterLoopNode = ProcessBlockRecursive(afterLoopBlock, context, x, targetY + 200); - if (firstAfterLoopNode != null) - { - var loopEndPin = loop.OutputPins.FirstOrDefault(p => p.Name == "LoopEnd"); - var firstExecIn = firstAfterLoopNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); - if (loopEndPin != null && firstExecIn != null) - { - Console.WriteLine($"[DEBUG] ProcessLoopNodeConnectionsInMain: Creating {loop.Name}.LoopEnd -> {firstAfterLoopNode.Name}.Exec"); - context.Blueprint.AddConnection(new BlueprintConnection - { - SourceNodeId = loop.Id, - SourcePinId = loopEndPin.Id, - TargetNodeId = firstAfterLoopNode.Id, - TargetPinId = firstExecIn.Id - }); - } - } - } - } - - // 处理 LoopBodyEndReturnTo - if (!string.IsNullOrEmpty(flowCtrl.LoopBodyEndReturnTo)) - { - context.LoopNodesByParentBlock[flowCtrl.LoopBodyEndReturnTo] = loop; - } - } - - private BlueprintNode? ProcessStatement(BlockStatement statement, ConversionContext context, double x, double y) - { - Console.WriteLine($"[DEBUG] ProcessStatement: {statement.GetType().Name}"); - switch (statement) - { - case FlowControlStatement flowCtrl: - Console.WriteLine($"[DEBUG] ProcessStatement: FlowControlStatement ControlType={flowCtrl.ControlType}, TrueBlock={flowCtrl.TrueBlockName}, FalseBlock={flowCtrl.FalseBlockName}, SourceCode={flowCtrl.SourceCode}"); - // ✅ NextBlock = Branch/Loop(...) 需要创建节点!不能跳过 - // 因为 Branch/Loop 节点有输出引脚需要连接到子块 - if ((flowCtrl.ControlType == FlowControlType.Branch || flowCtrl.ControlType == FlowControlType.Loop) && - !string.IsNullOrEmpty(flowCtrl.TrueBlockName) && - !string.IsNullOrEmpty(flowCtrl.FalseBlockName)) - { - // ✅ 对于 Loop 节点,检查是否已存在(避免重复创建) - if (flowCtrl.ControlType == FlowControlType.Loop && - context.CurrentParentBlock != null && - context.LoopNodesByParentBlock.TryGetValue(context.CurrentParentBlock, out var existingLoop)) - { - Console.WriteLine($"[DEBUG] ProcessStatement: Reusing existing Loop node for {context.CurrentParentBlock}"); - context.LastProcessedNode = existingLoop; - return existingLoop; - } - - Console.WriteLine($"[DEBUG] ProcessStatement: Creating {flowCtrl.ControlType} node for NextBlock = {flowCtrl.ControlType}(...)"); - var flowNode = ProcessFlowControlStatement(flowCtrl, context, x, y); - if (flowNode != null) - { - context.LastProcessedNode = flowNode; - } - return flowNode; - } - if (flowCtrl.ControlType == FlowControlType.LoopBodyEnd) - { - HandleLoopBodyEnd(flowCtrl, context); - return null; - } - var otherFlowNode = ProcessFlowControlStatement(flowCtrl, context, x, y); - if (otherFlowNode != null) - { - context.LastProcessedNode = otherFlowNode; - } - return otherFlowNode; - - case ExpressionStatement expr: - if (expr.Expression.StartsWith("NextBlock = ")) - return null; - var actionNode = CreateActionNodeFromExpression(expr.Expression, context); - if (actionNode != null) - { - context.LastProcessedNode = actionNode; - } - return actionNode; - - default: - return null; - } - } - - private void HandleLoopBodyEnd(FlowControlStatement flowCtrl, ConversionContext context) - { - if (!string.IsNullOrEmpty(flowCtrl.LoopBodyEndReturnTo)) - { - // ✅ 查找对应的Loop节点 - if (context.LoopNodesByParentBlock.TryGetValue(flowCtrl.LoopBodyEndReturnTo, out var loopNode)) - { - // ✅ 获取当前块中最后一个已处理的节点 - var lastNode = context.LastProcessedNode; - - if (lastNode != null) - { - // ✅ 建立从最后节点到Loop节点Condition输入的执行流回连 - var lastExecOut = lastNode.OutputPins.FirstOrDefault(p => p.Name == "Exec"); - var loopConditionIn = loopNode.InputPins.FirstOrDefault(p => p.Name == "Condition"); - - if (lastExecOut != null && loopConditionIn != null) - { - context.Blueprint.AddConnection(new BlueprintConnection - { - SourceNodeId = lastNode.Id, - SourcePinId = lastExecOut.Id, - TargetNodeId = loopNode.Id, - TargetPinId = loopConditionIn.Id, - PubVarName = null - }); - Log.Debug("Created LoopBodyEnd Exec return: {LastNode} -> Loop.Condition"); - } - } - - // ✅ 同时更新LoopNodesByParentBlock以便下次引用 - context.LoopNodesByParentBlock[flowCtrl.LoopBodyEndReturnTo] = loopNode; - } - } - } - - private BlueprintNode? ProcessFlowControlStatement(FlowControlStatement flowCtrl, ConversionContext context, double x, double y) - { - var node = CreateControlFlowNode(flowCtrl, context); - if (node == null) return null; - - node.X = x; - node.Y = y; - - // ✅ 不在这里添加到 blueprint,由调用者(ProcessStatement)负责添加 - // 这样可以避免重复添加 - - if (!string.IsNullOrEmpty(flowCtrl.ConditionExpression)) - { - CreateDataConnectionsForExpression(flowCtrl.ConditionExpression, node, context); - } - - double targetY = y + 150; - - switch (flowCtrl.ControlType) - { - case FlowControlType.Branch: - // ✅ 不在这里处理子块连接,由 ProcessBlockRecursive 通过 NextBlock 属性统一处理 - // 这样可以避免重复处理和 visited 问题 - Console.WriteLine($"[DEBUG] ProcessFlowControlStatement: Branch node created, TrueBlock={flowCtrl.TrueBlockName}, FalseBlock={flowCtrl.FalseBlockName}"); - break; - - case FlowControlType.Loop: - // ✅ 不在这里处理子块连接,由 ProcessBlockRecursive 通过 NextBlock 属性统一处理 - Console.WriteLine($"[DEBUG] ProcessFlowControlStatement: Loop node created, LoopBody={flowCtrl.TrueBlockName}, LoopEnd={flowCtrl.FalseBlockName}"); - break; - } - - return node; - } - - private BlueprintNode? ProcessBlockRecursive(BlockDefinition block, ConversionContext context, double x, double y) - { - Console.WriteLine($"[DEBUG] ProcessBlockRecursive: block={block.Name}, type={block.Type}, statements={block.Statements.Count}"); - - // 如果块已经访问过,返回第一个节点用于建立连接(而不是null) - if (context.VisitedBlocks.Contains(block.Name)) - { - var cachedFirstNode = context.BlockFirstNodes.GetValueOrDefault(block.Name); - Console.WriteLine($"[DEBUG] ProcessBlockRecursive: block {block.Name} ALREADY VISITED, returning {(cachedFirstNode != null ? cachedFirstNode.Name : "null")}"); - return cachedFirstNode; - } - context.VisitedBlocks.Add(block.Name); - - var nodes = new List(); - var currentY = y; - - Console.WriteLine($"[DEBUG] ProcessBlockRecursive: Processing {block.Statements.Count} statements for block {block.Name}"); - // 首先处理所有可执行语句节点 - foreach (var statement in block.Statements) - { - Console.WriteLine($"[DEBUG] ProcessBlockRecursive: Processing statement: {statement.GetType().Name}"); - var node = ProcessStatement(statement, context, x, currentY); - Console.WriteLine($"[DEBUG] ProcessBlockRecursive: ProcessStatement returned: {node?.Name} ({node?.Id})"); - if (node != null) - { - // ✅ 检查节点是否已经存在于 Blueprint 中,避免重复添加 - if (!context.Blueprint.Nodes.Any(n => n.Id == node.Id)) - { - Console.WriteLine($"[DEBUG] ProcessBlockRecursive: Adding node {node.Name} ({node.Id}) to blueprint"); - context.Blueprint.AddNode(node); - } - else - { - Console.WriteLine($"[DEBUG] ProcessBlockRecursive: Node {node.Name} ({node.Id}) already in blueprint, skipping"); - } - nodes.Add(node); - currentY += 120; - } - } - Console.WriteLine($"[DEBUG] ProcessBlockRecursive: After processing statements, nodes.Count = {nodes.Count}"); - - // 如果块只有路由语句(nodes为空),仍然需要处理路由以建立连接 - if (nodes.Count == 0 && block.Statements.Count > 0) - { - var firstStatement = block.Statements[0]; - if (firstStatement is FlowControlStatement flowCtrl && - (flowCtrl.ControlType == FlowControlType.Branch || flowCtrl.ControlType == FlowControlType.Loop)) - { - // 这是一个纯路由块,需要处理路由语句以建立连接 - Console.WriteLine($"[DEBUG] ProcessBlockRecursive: Processing routing-only block {block.Name}"); - var routingNode = ProcessFlowControlStatement(flowCtrl, context, x, y); - if (routingNode != null) - { - context.Blueprint.AddNode(routingNode); - nodes.Add(routingNode); - Console.WriteLine($"[DEBUG] ProcessBlockRecursive: Added routing node {routingNode.Name} ({routingNode.Id}) to nodes list"); - } - } - } - - LinkNodesWithExec(nodes, context.Blueprint); - - // ✅ 标记为已访问 - context.VisitedBlocks.Add(block.Name); - - // ✅ 处理 Branch/Loop 节点与子块的连接 - var flowNode = nodes.LastOrDefault(n => n is BranchNode || n is LoopNode); - if (flowNode != null) - { - Console.WriteLine($"[DEBUG] ProcessBlockRecursive: Processing flow node connections for {block.Name}"); - if (flowNode is BranchNode branch) - { - ProcessBranchNodeConnections(branch, block, context, x, y); - } - else if (flowNode is LoopNode loop) - { - ProcessLoopNodeConnections(loop, block, context, x, y); - } - } - - // 处理跨块连接 - if (!string.IsNullOrEmpty(block.NextBlockName) && - block.Type != BlockType.LoopBlock) - { - var nextBlock = context.Script.GetBlockByName(block.NextBlockName); - if (nextBlock != null && !context.VisitedBlocks.Contains(nextBlock.Name)) - { - if (nodes.Count > 0) - { - var lastNode = nodes.Last(); - var nextFirstNode = ProcessBlockRecursive(nextBlock, context, x + 300, y); - if (nextFirstNode != null) - { - var lastExecOut = lastNode.OutputPins.FirstOrDefault(p => p.Name == "Exec"); - var nextExecIn = nextFirstNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); - - // ✅ 验证是有效的 Exec 连接 - if (lastExecOut == null || lastExecOut.Direction != PinDirection.Output) - { - Log.Warning("Cannot create cross-block Exec connection: {Node}.Exec is not a valid output pin", - lastNode.Name); - } - else if (lastNode.Id == nextFirstNode.Id) - { - Log.Warning("Skipping self-loop cross-block Exec connection"); - } - else if (lastExecOut != null && nextExecIn != null) - { - context.Blueprint.AddConnection(new BlueprintConnection - { - SourceNodeId = lastNode.Id, - SourcePinId = lastExecOut.Id, - TargetNodeId = nextFirstNode.Id, - TargetPinId = nextExecIn.Id - }); - } - } - } - } - } - - // 存储块的第一个节点,以便后续已访问块的连接 - var firstNode = nodes.FirstOrDefault(); - if (firstNode != null) - { - context.BlockFirstNodes[block.Name] = firstNode; - } - - return firstNode; - } - - private void ProcessBranchNodeConnections(BranchNode branch, BlockDefinition block, ConversionContext context, double x, double y) - { - var callId = Guid.NewGuid().ToString().Substring(0, 8); - Console.WriteLine($"[DEBUG] ProcessBranchNodeConnections ENTER: callId={callId}, branch={branch.Id.Substring(0,8)}, block={block.Name}"); - // 找到对应的 FlowControlStatement - var flowCtrl = block.Statements.FirstOrDefault(s => s is FlowControlStatement) as FlowControlStatement; - if (flowCtrl == null) return; - - double targetY = y + 150; - - // 处理 True 分支 - if (!string.IsNullOrEmpty(flowCtrl.TrueBlockName)) - { - var trueBlock = context.Script.GetBlockByName(flowCtrl.TrueBlockName); - // ✅ 如果 trueBlock 已经访问过,跳过(避免重复创建连接) - if (trueBlock != null && context.VisitedBlocks.Contains(trueBlock.Name)) - { - Console.WriteLine($"[DEBUG] ProcessBranchNodeConnections[{callId}]: trueBlock {trueBlock.Name} already visited, skipping"); - } - else if (trueBlock != null) - { - var firstTrueNode = ProcessBlockRecursive(trueBlock, context, x + 300, targetY); - if (firstTrueNode != null && firstTrueNode.Id != branch.Id) - { - var truePin = branch.OutputPins.FirstOrDefault(p => p.Name == "True"); - var firstExecIn = firstTrueNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); - if (truePin != null && firstExecIn != null) - { - Console.WriteLine($"[DEBUG] ProcessBranchNodeConnections: Creating {branch.Name}.True -> {firstTrueNode.Name}.Exec"); - context.Blueprint.AddConnection(new BlueprintConnection - { - SourceNodeId = branch.Id, - SourcePinId = truePin.Id, - TargetNodeId = firstTrueNode.Id, - TargetPinId = firstExecIn.Id - }); - } - } - } - } - - // 处理 False 分支 - if (!string.IsNullOrEmpty(flowCtrl.FalseBlockName)) - { - var falseBlock = context.Script.GetBlockByName(flowCtrl.FalseBlockName); - if (falseBlock != null) - { - // ✅ 无论块是否已访问,都尝试获取第一个节点并建立连接 - // 对于已访问的块,从 BlockFirstNodes 获取;对于未访问的块,调用 ProcessBlockRecursive - BlueprintNode? firstFalseNode; - if (context.VisitedBlocks.Contains(falseBlock.Name)) - { - firstFalseNode = context.BlockFirstNodes.GetValueOrDefault(falseBlock.Name); - Console.WriteLine($"[DEBUG] ProcessBranchNodeConnections: falseBlock {falseBlock.Name} already visited, got first node from cache"); - } - else - { - firstFalseNode = ProcessBlockRecursive(falseBlock, context, x + 300, targetY + 200); - } - - Console.WriteLine($"[DEBUG] ProcessBranchNodeConnections: firstFalseNode = {firstFalseNode?.Name} ({firstFalseNode?.Id?.Substring(0,8) ?? "null"}), branch.Id = {branch.Id.Substring(0,8)}"); - - if (firstFalseNode == null) - { - Console.WriteLine($"[DEBUG] ProcessBranchNodeConnections: firstFalseNode is null, skipping"); - } - else if (firstFalseNode.Id == branch.Id) - { - Console.WriteLine($"[DEBUG] ProcessBranchNodeConnections: Skipping self-loop"); - } - else if (!context.Blueprint.Nodes.Any(n => n.Id == firstFalseNode.Id)) - { - Console.WriteLine($"[DEBUG] ProcessBranchNodeConnections: firstFalseNode NOT FOUND in Blueprint, skipping"); - } - else - { - // ✅ 如果目标是路由块(Branch/Loop),获取其实际可连接的子节点 - var actualTarget = GetFirstConnectableNode(firstFalseNode, context); - var falsePin = branch.OutputPins.FirstOrDefault(p => p.Name == "False"); - var actualExecIn = actualTarget?.InputPins.FirstOrDefault(p => p.Name == "Exec"); - - if (falsePin != null && actualExecIn != null && actualTarget != null && actualTarget.Id != branch.Id) - { - Console.WriteLine($"[DEBUG] ProcessBranchNodeConnections: Creating {branch.Name}.False -> {actualTarget.Name}.Exec"); - context.Blueprint.AddConnection(new BlueprintConnection - { - SourceNodeId = branch.Id, - SourcePinId = falsePin.Id, - TargetNodeId = actualTarget.Id, - TargetPinId = actualExecIn.Id - }); - } - } - } - } - - Console.WriteLine($"[DEBUG] ProcessBranchNodeConnections EXIT: callId={callId}, branch={branch.Id.Substring(0,8)}"); - } - - /// - /// 获取节点的可连接子节点。如果节点本身有来自外部(跨块)的 Exec 输入引脚连接,直接返回; - /// 如果是路由块(Branch/Loop)且其 Exec 输入只被内部连接,使用,则需要找到其连接的目标节点。 - /// - private BlueprintNode? GetFirstConnectableNode(BlueprintNode node, ConversionContext context) - { - // 如果节点本身有来自外部的 Exec 输入引脚连接,直接返回 - var execInputPin = node.InputPins.FirstOrDefault(p => p.Name == "Exec"); - if (execInputPin != null) - { - // 检查是否有从其他节点到这个节点 Exec 输入的连接(外部连接) - var hasExternalConnection = context.Blueprint.Connections - .Any(c => c.TargetNodeId == node.Id && - c.TargetPinId == execInputPin.Id && - c.SourceNodeId != node.Id); - if (hasExternalConnection) - { - return node; - } - } - - // 如果是 Branch 节点,找到其 True 输出连接的目标节点 - if (node is BranchNode branch) - { - var trueConn = context.Blueprint.Connections - .FirstOrDefault(c => c.SourceNodeId == branch.Id && - branch.OutputPins.Any(p => p.Id == c.SourcePinId && p.Name == "True")); - if (trueConn != null) - { - var targetNode = context.Blueprint.GetNodeById(trueConn.TargetNodeId); - if (targetNode != null) - { - // 递归获取实际可连接的节点 - return GetFirstConnectableNode(targetNode, context); - } - } - } - - // 如果是 Loop 节点,找到其 LoopBody 输出连接的目标节点 - if (node is LoopNode loop) - { - var loopBodyConn = context.Blueprint.Connections - .FirstOrDefault(c => c.SourceNodeId == loop.Id && - loop.OutputPins.Any(p => p.Id == c.SourcePinId && p.Name == "LoopBody")); - if (loopBodyConn != null) - { - var targetNode = context.Blueprint.GetNodeById(loopBodyConn.TargetNodeId); - if (targetNode != null) - { - return GetFirstConnectableNode(targetNode, context); - } - } - } - - return node; // 回退 - } - - private void ProcessLoopNodeConnections(LoopNode loop, BlockDefinition block, ConversionContext context, double x, double y) - { - // 找到对应的 FlowControlStatement - var flowCtrl = block.Statements.FirstOrDefault(s => s is FlowControlStatement) as FlowControlStatement; - if (flowCtrl == null) return; - - double targetY = y + 150; - - // 处理 LoopBody - if (!string.IsNullOrEmpty(flowCtrl.TrueBlockName)) - { - var loopBodyBlock = context.Script.GetBlockByName(flowCtrl.TrueBlockName); - if (loopBodyBlock != null) - { - // 注册 Loop 节点以便 LoopBodyEnd 引用 - context.LoopNodesByParentBlock[flowCtrl.TrueBlockName] = loop; - - var firstLoopNode = ProcessBlockRecursive(loopBodyBlock, context, x + 300, targetY); - if (firstLoopNode != null) - { - var loopBodyPin = loop.OutputPins.FirstOrDefault(p => p.Name == "LoopBody"); - var firstExecIn = firstLoopNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); - if (loopBodyPin != null && firstExecIn != null) - { - Console.WriteLine($"[DEBUG] ProcessLoopNodeConnections: Creating {loop.Name}.LoopBody -> {firstLoopNode.Name}.Exec"); - context.Blueprint.AddConnection(new BlueprintConnection - { - SourceNodeId = loop.Id, - SourcePinId = loopBodyPin.Id, - TargetNodeId = firstLoopNode.Id, - TargetPinId = firstExecIn.Id - }); - } - } - } - } - - // 处理 LoopEnd - if (!string.IsNullOrEmpty(flowCtrl.FalseBlockName)) - { - var afterLoopBlock = context.Script.GetBlockByName(flowCtrl.FalseBlockName); - if (afterLoopBlock != null) - { - var firstAfterLoopNode = ProcessBlockRecursive(afterLoopBlock, context, x + 300, targetY + 200); - if (firstAfterLoopNode != null) - { - var loopEndPin = loop.OutputPins.FirstOrDefault(p => p.Name == "LoopEnd"); - var firstExecIn = firstAfterLoopNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); - if (loopEndPin != null && firstExecIn != null) - { - Console.WriteLine($"[DEBUG] ProcessLoopNodeConnections: Creating {loop.Name}.LoopEnd -> {firstAfterLoopNode.Name}.Exec"); - context.Blueprint.AddConnection(new BlueprintConnection - { - SourceNodeId = loop.Id, - SourcePinId = loopEndPin.Id, - TargetNodeId = firstAfterLoopNode.Id, - TargetPinId = firstExecIn.Id - }); - } - } - } - } - - // 处理 LoopBodyEndReturnTo - if (!string.IsNullOrEmpty(flowCtrl.LoopBodyEndReturnTo)) - { - context.LoopNodesByParentBlock[flowCtrl.LoopBodyEndReturnTo] = loop; - } - } - - private BlueprintNode? CreateControlFlowNode(FlowControlStatement statement, ConversionContext context) - { - switch (statement.ControlType) - { - case FlowControlType.Branch: - return new BranchNode(); - case FlowControlType.Loop: - return new LoopNode(); - case FlowControlType.Break: - return new BreakNode(); - default: - return null; - } - } - - private static string GetMethodNameFromExpression(InvocationExpressionSyntax invoke) - { - if (invoke.Expression is IdentifierNameSyntax id) - return id.Identifier.Text; - if (invoke.Expression is GenericNameSyntax generic) - return generic.Identifier.Text; - if (invoke.Expression is MemberAccessExpressionSyntax member) - return member.Name.Identifier.Text; - return string.Empty; - } - - private BlueprintNode? CreateActionNodeFromExpression(string expression, ConversionContext context) - { - var parsed = TryParseInvocationExpression(expression); - if (parsed == null) - return null; - - var (funcName, args, assignment) = parsed.Value; - - if (funcName == "Get") - { - var varName = args?.Arguments.FirstOrDefault()?.Expression.ToString() ?? string.Empty; - - // ✅ 始终为 Get 创建独立的 GetNode - // 注意:不在此处添加到 Blueprint,由 ProcessBlockRecursive 统一添加 - var getNode = new GetNode - { - VarName = varName, - Name = $"Get:{varName}" - }; - Log.Debug("Created GetNode: VarName={VarName}, Id={NodeId}", varName, getNode.Id); - - var getValuePin = getNode.OutputPins.First(p => p.Name == "Value"); - - // ✅ 如果是赋值给 PubVar,注册数据源(使用唯一 key) - if (assignment != null) - { - var assignedVarName = assignment.Left.ToString(); - if (assignedVarName != "_") - { - var isPubVar = context.Script.PubVarBlock?.Variables - .Any(v => v.Name == assignedVarName) == true; - // ✅ 使用唯一 key(包含 Guid),避免嵌套复用 - context.VariableSources[$"__get_{assignedVarName}_{Guid.NewGuid():N}__"] = new VariableSource - { - Node = getNode, - Pin = getValuePin, - PubVarName = isPubVar ? assignedVarName : null - }; - Log.Debug("Registered GetNode output as VariableSource: {VarName}, IsPubVar={IsPubVar}", - assignedVarName, isPubVar); - } - } - - return getNode; // Return immediately — do NOT fall through to the CallNode block below - } - - if (funcName == "Set") - { - var argList = args?.Arguments.ToList() ?? new List(); - if (argList.Count >= 2) - { - var varName = argList[0].Expression.ToString(); - var valueExpr = argList[1].Expression; // ✅ 使用ExpressionSyntax而非ToString() - // 注意:不在此处添加到 Blueprint,由 ProcessBlockRecursive 统一添加 - var setNode = new SetNode - { - VarName = varName, - Name = $"Set:{varName}" - }; - Log.Debug("Created SetNode: VarName={VarName}, Id={NodeId}", varName, setNode.Id); - - // ✅ 获取Set节点的Value输入引脚 - var setValuePin = setNode.InputPins.FirstOrDefault(p => p.Name == "Value"); - if (setValuePin != null && valueExpr != null) - { - // ✅ 使用ProcessArgumentExpression处理嵌套函数调用 - ProcessArgumentExpression(valueExpr, setValuePin, setNode, context); - } - - return setNode; // Return immediately — do NOT fall through to the CallNode block below - } - return null; - } - - if (funcName == "Print") - { - var printNode = new PrintNode(); - var arg = args?.Arguments.FirstOrDefault()?.Expression.ToString() ?? string.Empty; - CreateDataConnectionsForExpression(arg, printNode, context); - return printNode; - } - - if (funcName == "Pause") - { - var pauseNode = new PauseNode(); - var arg = args?.Arguments.FirstOrDefault()?.Expression.ToString() ?? string.Empty; - CreateDataConnectionsForExpression(arg, pauseNode, context); - return pauseNode; - } - - if (assignment != null) - { - var varName = assignment.Left.ToString(); - - if (funcName is "Loop" or "Branch" or "LoopBodyEnd" or "Break") - { - Log.Debug("Skipped creating CallNode for flow control function: {FuncName}", funcName); - return null; - } - - // ✅ 检查是否应该复用已有的 VariableSource - // 如果 varName 已经有一个来源(相同函数的调用),则复用;否则创建新的 - var existingSource = context.VariableSources.Values - .FirstOrDefault(s => s.PubVarName == varName); - - BlueprintNode callNode; - if (existingSource != null) - { - // ✅ 复用已有的源节点 - callNode = existingSource.Node; - Log.Debug("Reused existing source node {NodeName} for {VarName}", callNode.Name, varName); - } - else - { - // ✅ 创建新的 Call 节点 - var isHelper = context.Script.HelperFunctions.Any(h => h.Name == funcName); - if (isHelper) - callNode = new CallHelperNode { HelperFunctionName = funcName, Name = $"Helper:{funcName}" }; - else - callNode = new CallNode { FunctionName = funcName, Name = $"Call:{funcName}" }; - - AddParameterPins(callNode, args?.Arguments); - CreateDataConnectionsForArguments(args?.Arguments, callNode, context); - - // ✅ 注册新的数据源 - var returnPin = callNode.OutputPins.FirstOrDefault(p => p.Name == "Return"); - if (returnPin != null) - { - // Check if varName (assignment target) is a PubVar - var isPubVar = context.Script.PubVarBlock?.Variables - .Any(v => v.Name == varName) == true; - context.VariableSources[varName] = new VariableSource - { - Node = callNode, - Pin = returnPin, - PubVarName = isPubVar ? varName : null // ✅ Set PubVarName for PubVar - }; - } - } - - return callNode; - } - - if (funcName is "Loop" or "Branch" or "LoopBodyEnd" or "Break") - { - Log.Debug("Skipped creating CallNode for flow control/builtin function: {FuncName}", funcName); - return null; - } - - BlueprintNode callNode2; - var isHelper2 = context.Script.HelperFunctions.Any(h => h.Name == funcName); - if (isHelper2) - callNode2 = new CallHelperNode { HelperFunctionName = funcName, Name = $"Helper:{funcName}" }; - else - callNode2 = new CallNode { FunctionName = funcName, Name = $"Call:{funcName}" }; - - AddParameterPins(callNode2, args?.Arguments); - CreateDataConnectionsForArguments(args?.Arguments, callNode2, context); - - return callNode2; - } - - private (string? funcName, ArgumentListSyntax? args, AssignmentExpressionSyntax? assignment)? TryParseInvocationExpression(string expression) - { - var wrappedCode = $"_ = {expression};"; - var syntaxTree = CSharpSyntaxTree.ParseText(wrappedCode, cancellationToken: CancellationToken.None); - var root = syntaxTree.GetCompilationUnitRoot(); - - var globalStmt = root.Members.FirstOrDefault() as GlobalStatementSyntax; - var stmt = globalStmt?.Statement as ExpressionStatementSyntax; - - if (stmt?.Expression is AssignmentExpressionSyntax outerAssignment) - { - ExpressionSyntax rightExpr = outerAssignment.Right; - // Track the innermost assignment that has a non-underscore variable on the left - AssignmentExpressionSyntax? innermostAssignment = null; - while (rightExpr is AssignmentExpressionSyntax nestedAssignment) - { - innermostAssignment = nestedAssignment; - rightExpr = nestedAssignment.Right; - } - - if (rightExpr is InvocationExpressionSyntax invoke) - { - var methodName = GetMethodNameFromExpression(invoke); - // Use the innermost assignment if available, otherwise the outermost - var assignmentToReturn = innermostAssignment ?? outerAssignment; - return (methodName, invoke.ArgumentList, assignmentToReturn); - } - } - else if (stmt?.Expression is InvocationExpressionSyntax directInvoke) - { - var methodName = GetMethodNameFromExpression(directInvoke); - return (methodName, directInvoke.ArgumentList, null); - } - return null; - } - - private void AddParameterPins(BlueprintNode node, SeparatedSyntaxList? arguments) - { - if (!arguments.HasValue || arguments.Value.Count == 0) return; - - foreach (var arg in arguments.Value) - { - node.InputPins.Add(new BlueprintPin - { - Name = $"param{node.InputPins.Count}", - Direction = PinDirection.Input, - Type = PinType.Any - }); - } - } - - private void ProcessLoopBlock(BlockDefinition block, ConversionContext context) - { - if (context.VisitedBlocks.Contains(block.Name)) return; - context.VisitedBlocks.Add(block.Name); - - // ✅ 设置当前父块名称,以便复用 Loop 节点 - var parentBlockName = block.ParentBlockName ?? block.Name; - context.CurrentParentBlock = parentBlockName; - - var nodes = new List(); - var currentY = 50.0; - - foreach (var statement in block.Statements) - { - var node = ProcessStatement(statement, context, 50, currentY); - if (node != null) - { - context.Blueprint.AddNode(node); - nodes.Add(node); - currentY += 120; - } - } - - LinkNodesWithExec(nodes, context.Blueprint); - - foreach (var statement in block.Statements) - { - if (statement is FlowControlStatement flowCtrl) - { - if (!string.IsNullOrEmpty(flowCtrl.TrueBlockName)) - { - var trueBlock = context.Script.GetBlockByName(flowCtrl.TrueBlockName); - if (trueBlock != null) - ProcessBlockRecursive(trueBlock, context, 350, 50); - } - if (!string.IsNullOrEmpty(flowCtrl.FalseBlockName)) - { - var falseBlock = context.Script.GetBlockByName(flowCtrl.FalseBlockName); - if (falseBlock != null) - ProcessBlockRecursive(falseBlock, context, 350, 200); - } - } - } - } - - private void LinkNodesWithExec(List nodes, Contract.Workflow.Blueprint blueprint) - { - Console.WriteLine($"[DEBUG] === LinkNodesWithExec called with {nodes.Count} nodes: {string.Join(", ", nodes.Select(n => $"{n.Name}({n.Id.Substring(0,8)})"))}"); - - for (int i = 0; i < nodes.Count - 1; i++) - { - var currentNode = nodes[i]; - var nextNode = nodes[i + 1]; - - // ✅ 跳过自环连接 - if (currentNode.Id == nextNode.Id) - { - Console.WriteLine($"[DEBUG] LinkNodesWithExec: Skipping self-loop for {currentNode.Name} ({currentNode.Id.Substring(0,8)})"); - continue; - } - - // ✅ 获取 currentNode 的 Exec 输出引脚 - var currentExecOut = currentNode.OutputPins.FirstOrDefault(p => p.Name == "Exec"); - - // ✅ Branch 和 Loop 节点没有 Exec 输出引脚,跳过 - if (currentExecOut == null) - { - Log.Debug("Skipping Exec link: {Node} has no Exec output pin", currentNode.Name); - continue; - } - - // ✅ 确保 currentExecOut 是 OUTPUT 类型的引脚 - if (currentExecOut.Direction != PinDirection.Output) - { - Log.Warning("Cannot create Exec connection: {Node}.Exec is not a valid output pin", currentNode.Name); - continue; - } - - // ✅ 获取 nextNode 的 Exec 输入引脚 - var nextExecIn = nextNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); - if (nextExecIn == null) - { - Log.Warning("Cannot create Exec connection: {Node} has no Exec input pin", nextNode.Name); - continue; - } - - Console.WriteLine($"[DEBUG] LinkNodesWithExec: Creating {currentNode.Name}.{currentExecOut.Name} -> {nextNode.Name}.{nextExecIn.Name}"); - - blueprint.AddConnection(new BlueprintConnection - { - SourceNodeId = currentNode.Id, - SourcePinId = currentExecOut.Id, - TargetNodeId = nextNode.Id, - TargetPinId = nextExecIn.Id - }); - } - } - - private void LayoutNodes(Contract.Workflow.Blueprint blueprint) - { - var depths = new Dictionary(); - var visited = new HashSet(); - - var entry = blueprint.Nodes.FirstOrDefault(n => n.NodeType == BlueprintNodeType.Entry); - if (entry != null) - { - depths[entry.Id] = 0; - CalculateDepths(blueprint, entry, depths, visited); - } - - var depthGroups = new Dictionary>(); - foreach (var node in blueprint.Nodes) - { - if (depths.TryGetValue(node.Id, out var depth)) - { - if (!depthGroups.ContainsKey(depth)) - depthGroups[depth] = new List(); - depthGroups[depth].Add(node); - } - } - - foreach (var group in depthGroups) - { - var depth = group.Key; - var nodes = group.Value; - for (int i = 0; i < nodes.Count; i++) - { - nodes[i].X = 50 + depth * 300; - nodes[i].Y = 50 + i * 120; - } - } - } - - private void CalculateDepths(Contract.Workflow.Blueprint blueprint, BlueprintNode node, Dictionary depths, HashSet visited) - { - if (visited.Contains(node.Id)) return; - visited.Add(node.Id); - - var outConnections = blueprint.GetConnectionsFrom(node.Id); - foreach (var conn in outConnections) - { - if (blueprint.GetNodeById(conn.TargetNodeId) is { } targetNode) - { - var currentDepth = depths[node.Id]; - if (!depths.ContainsKey(targetNode.Id) || depths[targetNode.Id] < currentDepth + 1) - { - depths[targetNode.Id] = currentDepth + 1; - } - CalculateDepths(blueprint, targetNode, depths, visited); - } - } - } - - private bool IsVariableReference(string token) - { - if (string.IsNullOrWhiteSpace(token)) return false; - if (bool.TryParse(token, out _)) return false; - if (int.TryParse(token, out _) || double.TryParse(token, out _)) return false; - if (token.StartsWith("\"") && token.EndsWith("\"")) return false; - if (token.StartsWith("'") && token.EndsWith("'")) return false; - if (token.Contains('(')) return false; - return System.Text.RegularExpressions.Regex.IsMatch(token, @"^[a-zA-Z_]\w*$"); - } - - private void CreateDataConnectionsForExpression(string expression, BlueprintNode targetNode, ConversionContext context) - { - var wrappedCode = $"_ = {expression};"; - var syntaxTree = CSharpSyntaxTree.ParseText(wrappedCode, cancellationToken: CancellationToken.None); - var root = syntaxTree.GetCompilationUnitRoot(); - var globalStmt = root.Members.FirstOrDefault() as GlobalStatementSyntax; - var stmt = globalStmt?.Statement as ExpressionStatementSyntax; - - if (stmt?.Expression == null) return; - - ProcessExpressionForDataConnections(stmt.Expression, targetNode, context); - } - - private void ProcessExpressionForDataConnections(ExpressionSyntax expr, BlueprintNode targetNode, ConversionContext context) - { - switch (expr) - { - case LiteralExpressionSyntax literal: - CreateConstNodeForLiteral(literal, targetNode, context); - break; - - case IdentifierNameSyntax identifier: - CreateDataConnectionForVariable(identifier.Identifier.Text, targetNode, context); - break; - - case AssignmentExpressionSyntax assignment: - ProcessExpressionForDataConnections(assignment.Right, targetNode, context); - break; - - case InvocationExpressionSyntax invoke: - var funcName = GetMethodNameFromExpression(invoke); - - // ✅ 注意:Get 和 Set 的处理在 ProcessArgumentExpression 中已正确完成 - // 这里只需要处理其他函数调用(HelperFuncAdd 等)的情况 - if (funcName != "Get" && funcName != "Set") - { - // ✅ 始终创建新的 Call 节点(不复用) - // 因为 condition 表达式(BEQ/BLT/BLE)是不同的函数调用,不应该复用 - var isHelper = context.Script.HelperFunctions.Any(h => h.Name == funcName); - BlueprintNode callNode; - if (isHelper) - callNode = new CallHelperNode { HelperFunctionName = funcName, Name = $"Helper:{funcName}" }; - else - callNode = new CallNode { FunctionName = funcName, Name = $"Call:{funcName}" }; - - AddParameterPins(callNode, invoke.ArgumentList.Arguments); - context.Blueprint.AddNode(callNode); - - // ✅ 处理参数的连接 - CreateDataConnectionsForArguments(invoke.ArgumentList.Arguments, callNode, context); - - // ✅ 将函数调用的返回值连接到 targetNode 的条件引脚 - var returnPin = callNode.OutputPins.FirstOrDefault(p => p.Name == "Return"); - var targetPin = targetNode.InputPins.FirstOrDefault(p => p.Name != "Exec"); - if (returnPin != null && targetPin != null) - { - context.Blueprint.AddConnection(new BlueprintConnection - { - SourceNodeId = callNode.Id, - SourcePinId = returnPin.Id, - TargetNodeId = targetNode.Id, - TargetPinId = targetPin.Id, - PubVarName = null - }); - } - } - break; - - default: - var exprStr = expr.ToString(); - if (IsVariableReference(exprStr)) - { - CreateDataConnectionForVariable(exprStr, targetNode, context); - } - break; - } - } - - private void CreateConstNodeForLiteral(LiteralExpressionSyntax literal, BlueprintNode targetNode, ConversionContext context) - { - var targetPin = targetNode.InputPins.FirstOrDefault(p => p.Name != "Exec"); - if (targetPin == null) return; - - if (literal.Token.Value is string strVal) - { - // ✅ 不再创建 ConstNode,直接设置预设值 - targetPin.DefaultValue = strVal; - Log.Debug("Set DefaultValue for {TargetNode}.{TargetPin}: {Value}", targetNode.Name, targetPin.Name, strVal); - } - else if (literal.Token.Value is int intVal) - { - // ✅ 不再创建 ConstNode,直接设置预设值 - targetPin.DefaultValue = intVal.ToString(); - Log.Debug("Set DefaultValue for {TargetNode}.{TargetPin}: {Value}", targetNode.Name, targetPin.Name, intVal); - } - } - - /// - /// Sets DefaultValue for a literal and connects it to a specific parameter pin. - /// Used by ProcessArgumentExpression to set literal defaults for exact parameter pins. - /// - private void CreateConstNodeForLiteralWithPin(LiteralExpressionSyntax literal, BlueprintPin targetPin, BlueprintNode targetNode, ConversionContext context) - { - if (literal.Token.Value is string strVal) - { - // ✅ 不再创建 ConstNode,直接设置预设值 - targetPin.DefaultValue = strVal; - Log.Debug("Set DefaultValue for {TargetNode}.{TargetPin}: {Value}", targetNode.Name, targetPin.Name, strVal); - } - else if (literal.Token.Value is int intVal) - { - // ✅ 不再创建 ConstNode,直接设置预设值 - targetPin.DefaultValue = intVal.ToString(); - Log.Debug("Set DefaultValue for {TargetNode}.{TargetPin}: {Value}", targetNode.Name, targetPin.Name, intVal); - } - } - - private void CreateDataConnectionsForArguments(SeparatedSyntaxList? arguments, BlueprintNode callNode, ConversionContext context) - { - if (!arguments.HasValue || arguments.Value.Count == 0) return; - - // NOTE: AddParameterPins names parameters using node.InputPins.Count at the time of creation. - // Since CallNode/CallHelperNode already has Exec (at count=0), the first parameter gets name "param1". - // Therefore paramIndex starts at 1 to match the naming convention. - var paramIndex = 1; - foreach (var arg in arguments.Value) - { - var paramPin = callNode.InputPins.FirstOrDefault(p => p.Name == $"param{paramIndex}"); - if (paramPin == null) break; - paramIndex++; - ProcessArgumentExpression(arg.Expression, paramPin, callNode, context); - } - } - - private void ProcessArgumentExpression(ExpressionSyntax expr, BlueprintPin targetPin, BlueprintNode callNode, ConversionContext context) - { - switch (expr) - { - case InvocationExpressionSyntax invoke: - var funcName = GetMethodNameFromExpression(invoke); - Log.Debug("[ProcessArgumentExpression] InvocationExpressionSyntax: funcName={FuncName}, expr={Expr}", - funcName, expr.ToString()); - - if (funcName == "Get") - { - var varName = invoke.ArgumentList.Arguments.FirstOrDefault()?.Expression.ToString() ?? string.Empty; - - // ✅ Check if varName is a PubVarBlock variable - var isPubVar = context.Script.PubVarBlock?.Variables - .Any(v => v.Name == varName) == true; - - if (isPubVar) - { - // ✅ PubVar: 不创建 GetNode,直接从 VariableSources 获取 - // 查找时使用简单变量名(PubVarNames 中记录的名称) - var source = context.VariableSources.Values - .FirstOrDefault(s => s.PubVarName == varName); - if (source != null) - { - context.Blueprint.AddConnection(new BlueprintConnection - { - SourceNodeId = source.Node.Id, - SourcePinId = source.Pin.Id, - TargetNodeId = callNode.Id, - TargetPinId = targetPin.Id, - PubVarName = varName // ✅ PubVar 变量设置 PubVarName - }); - } - else - { - Log.Warning("[ProcessArgumentExpression] PubVar {VarName} not found in VariableSources", varName); - } - } - else - { - // ✅ ConstBlock variable: 创建独立的 GetNode(不连接到 ConstNode) - var getNode = new GetNode { VarName = varName, Name = $"Get:{varName}" }; - context.Blueprint.AddNode(getNode); - var getValuePin = getNode.OutputPins.First(p => p.Name == "Value"); - - // 连接到目标参数(GetNode 通过 VarName 在运行时获取 ConstBlock 值) - context.Blueprint.AddConnection(new BlueprintConnection - { - SourceNodeId = getNode.Id, - SourcePinId = getValuePin.Id, - TargetNodeId = callNode.Id, - TargetPinId = targetPin.Id, - PubVarName = null // ✅ ConstBlock 变量 - }); - } - } - else if (funcName == "Set") - { - var args = invoke.ArgumentList.Arguments; - if (args.Count >= 2) - { - var varName = args[0].Expression.ToString(); - var valueExpr = args[1].Expression; - var setNode = new SetNode { VarName = varName, Name = $"Set:{varName}" }; - context.Blueprint.AddNode(setNode); - var setValuePin = setNode.InputPins.FirstOrDefault(p => p.Name == "Value"); - if (setValuePin != null) - { - ProcessArgumentExpression(valueExpr, setValuePin, setNode, context); - } - } - } - else - { - var isHelper = context.Script.HelperFunctions.Any(h => h.Name == funcName); - BlueprintNode innerCallNode = isHelper - ? new CallHelperNode { HelperFunctionName = funcName, Name = $"Helper:{funcName}" } - : new CallNode { FunctionName = funcName, Name = $"Call:{funcName}" }; - - AddParameterPins(innerCallNode, invoke.ArgumentList.Arguments); - context.Blueprint.AddNode(innerCallNode); - CreateDataConnectionsForArguments(invoke.ArgumentList.Arguments, innerCallNode, context); - - var returnPin = innerCallNode.OutputPins.FirstOrDefault(p => p.Name == "Return"); - if (returnPin != null) - { - context.Blueprint.AddConnection(new BlueprintConnection - { - SourceNodeId = innerCallNode.Id, - SourcePinId = returnPin.Id, - TargetNodeId = callNode.Id, - TargetPinId = targetPin.Id, - PubVarName = null - }); - context.VariableSources[$"__call_{funcName}_{Guid.NewGuid():N}__"] = new VariableSource - { - Node = innerCallNode, - Pin = returnPin, - PubVarName = null - }; - } - } - break; - - case LiteralExpressionSyntax literal: - CreateConstNodeForLiteralWithPin(literal, targetPin, callNode, context); - break; - - case IdentifierNameSyntax identifier: - CreateDataConnectionForVariable(identifier.Identifier.Text, targetPin, callNode, context); - break; - - default: - var exprStr = expr.ToString(); - if (IsVariableReference(exprStr)) - { - CreateDataConnectionForVariable(exprStr, targetPin, callNode, context); - } - break; - } - } - - private void CreateDataConnectionForVariable(string varName, BlueprintPin targetPin, BlueprintNode callNode, ConversionContext context) - { - if (context.Script.ConstBlock?.Variables.Any(v => v.Name == varName) == true) - { - // ✅ ConstBlock 变量:直接从 ConstNode 建立连接到目标节点 - var constNode = context.Blueprint.Nodes.OfType().FirstOrDefault(c => c.ConstName == varName); - if (constNode != null) - { - var constPin = constNode.OutputPins.First(p => p.Name == "Value"); - context.Blueprint.AddConnection(new BlueprintConnection - { - SourceNodeId = constNode.Id, - SourcePinId = constPin.Id, - TargetNodeId = callNode.Id, - TargetPinId = targetPin.Id, - PubVarName = null // ✅ ConstBlock 变量不应设置 PubVarName - }); - } - } - else - { - // ✅ PubVar 或其他变量:按 PubVarName 查找 VariableSources - var source = context.VariableSources.Values.FirstOrDefault(s => s.PubVarName == varName); - if (source != null) - { - context.Blueprint.AddConnection(new BlueprintConnection - { - SourceNodeId = source.Node.Id, - SourcePinId = source.Pin.Id, - TargetNodeId = callNode.Id, - TargetPinId = targetPin.Id, - PubVarName = source.PubVarName - }); - } - } - } - - private void CreateDataConnectionForVariable(string varName, BlueprintNode targetNode, ConversionContext context) - { - var targetPin = targetNode.InputPins.FirstOrDefault(p => p.Name != "Exec"); - if (targetPin == null) return; - - if (context.Script.ConstBlock?.Variables.Any(v => v.Name == varName) == true) - { - // ✅ ConstBlock 变量:直接从 ConstNode 建立连接到目标节点 - var constNode = context.Blueprint.Nodes.OfType().FirstOrDefault(c => c.ConstName == varName); - if (constNode != null) - { - var constPin = constNode.OutputPins.First(p => p.Name == "Value"); - context.Blueprint.AddConnection(new BlueprintConnection - { - SourceNodeId = constNode.Id, - SourcePinId = constPin.Id, - TargetNodeId = targetNode.Id, - TargetPinId = targetPin.Id, - PubVarName = null // ✅ ConstBlock 变量不应设置 PubVarName - }); - } - } - else - { - // ✅ PubVar 或其他变量:按 PubVarName 查找 VariableSources - var source = context.VariableSources.Values.FirstOrDefault(s => s.PubVarName == varName); - if (source != null) - { - context.Blueprint.AddConnection(new BlueprintConnection - { - SourceNodeId = source.Node.Id, - SourcePinId = source.Pin.Id, - TargetNodeId = targetNode.Id, - TargetPinId = targetPin.Id, - PubVarName = source.PubVarName - }); - } - } - } - - public class VariableSource - { - public BlueprintNode Node { get; set; } = null!; - public BlueprintPin Pin { get; set; } = null!; - /// 如果是 PubVar,则记录 PubVarName;否则为 null - public string? PubVarName { get; set; } - } - - internal class ConversionContext - { - public required Contract.Workflow.Blueprint Blueprint { get; set; } - public required BlockScript Script { get; set; } - public Dictionary NodeMap { get; set; } = new(); - public Dictionary NamedBlockMap { get; set; } = new(); - public Dictionary BlockFirstNodes { get; set; } = new(); - public EntryNode? EntryNode { get; set; } - public int NextPubVarIndex { get; set; } - public HashSet VisitedBlocks { get; set; } = new(); - public Dictionary VariableSources { get; set; } = new(); - public List DataConnections { get; set; } = new(); - public Dictionary LoopNodesByParentBlock { get; set; } = new(); - public BlueprintNode? LastProcessedNode { get; set; } - /// - /// 当前正在处理的父块名称(用于 Loop 节点复用) - /// - public string? CurrentParentBlock { get; set; } - } } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlockScriptToBlueprintConverter.cs.bak b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlockScriptToBlueprintConverter.cs.bak new file mode 100644 index 00000000..8242b168 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlockScriptToBlueprintConverter.cs.bak @@ -0,0 +1,1714 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using KitX.Core.Contract.Workflow; +using Serilog; + +namespace KitX.Core.Workflow.Blueprint; + +/// +/// Converts BlockScript to Blueprint +/// +public class BlockScriptToBlueprintConverter : IBlockScriptToBlueprintConverter +{ + private readonly IBlockScriptParser _parser; + + public BlockScriptToBlueprintConverter(IBlockScriptParser parser) + { + _parser = parser; + } + + public Contract.Workflow.Blueprint Convert(string sourceCode, List? helperFunctions = null) + { + var result = _parser.Parse(sourceCode); + if (!result.IsSuccess || result.Script == null) + { + throw new InvalidOperationException($"Failed to parse BlockScript: {result.ErrorMessage}"); + } + return Convert(result.Script); + } + + public Contract.Workflow.Blueprint Convert(BlockScript script) + { + var blueprint = new Contract.Workflow.Blueprint + { + Name = "Imported from BlockScript", + HelperFunctions = script.HelperFunctions, + PubVarNames = new List() + }; + + var context = new ConversionContext + { + Blueprint = blueprint, + Script = script, + NodeMap = new Dictionary(), + NextPubVarIndex = 0 + }; + + // Process ConstBlock + if (script.ConstBlock != null) + { + ProcessConstBlock(script.ConstBlock, context); + } + + // Process PubVarBlock + if (script.PubVarBlock != null) + { + ProcessPubVarBlock(script.PubVarBlock, context); + } + + // Process MainBlock - create Entry node and main execution flow + if (script.MainBlock != null) + { + ProcessMainBlock(script.MainBlock, context); + } + + // Process NamedBlocks (for calls from main flow) + foreach (var kvp in script.NamedBlocks) + { + context.NamedBlockMap[kvp.Key] = kvp.Value; + } + + // Process LoopBlocks + foreach (var kvp in script.LoopBlocks) + { + ProcessLoopBlock(kvp.Value, context); + } + + // Auto-layout nodes + LayoutNodes(blueprint); + + Log.Information("[BlueprintConversion] Complete: {NodeCount} nodes, {ConnectionCount} connections", + blueprint.Nodes.Count, blueprint.Connections.Count); + + return blueprint; + } + + private void ProcessConstBlock(BlockDefinition block, ConversionContext context) + { + foreach (var variable in block.Variables) + { + // ✅ 如果 ConstValue 为空,根据类型设置默认值 + var constValue = variable.DefaultValue?.ToString(); + if (string.IsNullOrEmpty(constValue)) + { + constValue = variable.Type.ToLower() switch + { + "int" or "integer" or "long" or "short" or "byte" => "0", + "float" or "double" or "decimal" => "0", + "bool" or "boolean" => "false", + "string" => "", + "char" => "\0", + _ => "" + }; + } + + var constNode = new ConstNode + { + ConstName = variable.Name, + ConstType = variable.Type, + ConstValue = constValue + }; + context.Blueprint.AddNode(constNode); + context.Blueprint.ConstValues.Add(new VariableConstant + { + Name = variable.Name, + Type = variable.Type, + DefaultValue = variable.DefaultValue + }); + Log.Debug("Created ConstNode: Name={ConstName}, Id={NodeId}", constNode.Name, constNode.Id); + } + } + + private void ProcessPubVarBlock(BlockDefinition block, ConversionContext context) + { + foreach (var variable in block.Variables) + { + context.Blueprint.PubVarNames.Add(variable.Name); + } + } + + private void ProcessMainBlock(BlockDefinition block, ConversionContext context) + { + // Create Entry node + var entryNode = new EntryNode + { + X = 50, + Y = 50 + }; + context.Blueprint.AddNode(entryNode); + context.EntryNode = entryNode; + Log.Debug("Created EntryNode: Name={NodeName}, Id={NodeId}", entryNode.Name, entryNode.Id); + + // Process all statements in main block + var mainFlowNodes = new List(); + var currentY = 150.0; + + Console.WriteLine($"[DEBUG] ProcessMainBlock: Starting foreach, Statements.Count = {block.Statements.Count}"); + for (int i = 0; i < block.Statements.Count; i++) + { + var statement = block.Statements[i]; + var sourcePreview = statement.SourceCode?.Length > 50 + ? statement.SourceCode.Substring(0, 50) + "..." + : statement.SourceCode ?? "(null)"; + Console.WriteLine($"[DEBUG] ProcessMainBlock: Processing statement {i}: {statement.GetType().Name}, SourceCode: {sourcePreview}"); + var node = ProcessStatement(statement, context, 50, currentY); + Console.WriteLine($"[DEBUG] ProcessMainBlock: ProcessStatement returned: {node?.Name} ({node?.Id})"); + if (node != null) + { + Console.WriteLine($"[DEBUG] ProcessMainBlock: Adding node {node.Name} ({node.Id}) to blueprint"); + context.Blueprint.AddNode(node); + mainFlowNodes.Add(node); + currentY += 120; + } + } + Console.WriteLine($"[DEBUG] ProcessMainBlock: Finished foreach, mainFlowNodes.Count = {mainFlowNodes.Count}"); + + // Link main flow with Exec connections + LinkNodesWithExec(mainFlowNodes, context.Blueprint); + + Console.WriteLine($"[DEBUG] ProcessMainBlock: mainFlowNodes count = {mainFlowNodes.Count}"); + for (int i = 0; i < mainFlowNodes.Count; i++) + { + Console.WriteLine($"[DEBUG] ProcessMainBlock: mainFlowNodes[{i}] = {mainFlowNodes[i].Name} ({mainFlowNodes[i].Id})"); + } + + // ✅ 处理 Branch/Loop 节点与子块的连接 + // 注意:Loop 节点存储在 LoopBlocks["MainBlock"] 中,不在 MainBlock.Statements + // 因此需要从 context.LoopNodesByParentBlock 中查找 + var flowNode = mainFlowNodes.LastOrDefault(n => n is BranchNode || n is LoopNode); + FlowControlStatement? flowCtrl = null; + + // 如果 mainFlowNodes 中没有找到 Loop,尝试从 LoopBlocks 处理 + if (flowNode == null) + { + // 检查 LoopBlocks 中是否有 MainBlock 的 Loop + if (context.Script.LoopBlocks.TryGetValue("MainBlock", out var loopBlock)) + { + // 获取 Loop FlowControlStatement + flowCtrl = loopBlock.Statements.FirstOrDefault(s => s is FlowControlStatement) as FlowControlStatement; + if (flowCtrl != null) + { + // ✅ 设置 CurrentParentBlock 以便后续 ProcessLoopBlock 复用此 Loop 节点 + context.CurrentParentBlock = "MainBlock"; + + // 在 LoopBlocks["MainBlock"] 中创建 Loop 节点 + var loopNodeInLoopBlock = ProcessFlowControlStatement(flowCtrl, context, 350, 150); + if (loopNodeInLoopBlock is LoopNode loopFromBlock) + { + flowNode = loopFromBlock; + // ✅ 注册到 LoopNodesByParentBlock 以便后续使用 + context.LoopNodesByParentBlock["MainBlock"] = loopFromBlock; + Console.WriteLine($"[DEBUG] ProcessMainBlock: Created Loop node from LoopBlocks, registering as MainBlock's Loop"); + } + } + } + } + else + { + // Branch/Loop 在 MainBlock.Statements 中 + flowCtrl = block.Statements.LastOrDefault(s => s is FlowControlStatement) as FlowControlStatement; + } + + Console.WriteLine($"[DEBUG] ProcessMainBlock: flowNode = {flowNode?.Name} ({flowNode?.Id})"); + if (flowNode != null && flowCtrl != null) + { + Console.WriteLine($"[DEBUG] ProcessMainBlock: Processing flow node connections"); + if (flowNode is BranchNode branch) + { + ProcessBranchNodeConnectionsInMain(branch, flowCtrl, context); + } + else if (flowNode is LoopNode loop) + { + ProcessLoopNodeConnectionsInMain(loop, flowCtrl, context); + } + } + + // Link Entry to first main flow node + if (mainFlowNodes.Count > 0) + { + var firstNode = mainFlowNodes[0]; + var entryExecPin = entryNode.OutputPins.First(p => p.Name == "Exec"); + var firstExecPin = firstNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); + if (entryExecPin != null && firstExecPin != null) + { + context.Blueprint.AddConnection(new BlueprintConnection + { + SourceNodeId = entryNode.Id, + SourcePinId = entryExecPin.Id, + TargetNodeId = firstNode.Id, + TargetPinId = firstExecPin.Id + }); + } + } + } + + private void ProcessBranchNodeConnectionsInMain(BranchNode branch, FlowControlStatement flowCtrl, ConversionContext context) + { + double x = 350; + double targetY = 300; + + // 处理 True 分支 + if (!string.IsNullOrEmpty(flowCtrl.TrueBlockName)) + { + var trueBlock = context.Script.GetBlockByName(flowCtrl.TrueBlockName); + if (trueBlock != null) + { + var firstTrueNode = ProcessBlockRecursive(trueBlock, context, x, targetY); + if (firstTrueNode != null) + { + var truePin = branch.OutputPins.FirstOrDefault(p => p.Name == "True"); + var firstExecIn = firstTrueNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); + if (truePin != null && firstExecIn != null) + { + Console.WriteLine($"[DEBUG] ProcessBranchNodeConnectionsInMain: Creating {branch.Name}.True -> {firstTrueNode.Name}.Exec"); + context.Blueprint.AddConnection(new BlueprintConnection + { + SourceNodeId = branch.Id, + SourcePinId = truePin.Id, + TargetNodeId = firstTrueNode.Id, + TargetPinId = firstExecIn.Id + }); + } + } + } + } + + // 处理 False 分支 + if (!string.IsNullOrEmpty(flowCtrl.FalseBlockName)) + { + var falseBlock = context.Script.GetBlockByName(flowCtrl.FalseBlockName); + if (falseBlock != null) + { + // ✅ 如果 falseBlock 已经访问过,跳过(避免重复创建连接) + if (context.VisitedBlocks.Contains(falseBlock.Name)) + { + Console.WriteLine($"[DEBUG] ProcessBranchNodeConnectionsInMain: falseBlock {falseBlock.Name} already visited, skipping"); + } + else + { + var firstFalseNode = ProcessBlockRecursive(falseBlock, context, x, targetY + 200); + if (firstFalseNode != null && firstFalseNode.Id != branch.Id) + { + var falsePin = branch.OutputPins.FirstOrDefault(p => p.Name == "False"); + var firstExecIn = firstFalseNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); + if (falsePin != null && firstExecIn != null) + { + Console.WriteLine($"[DEBUG] ProcessBranchNodeConnectionsInMain: Creating {branch.Name}.False -> {firstFalseNode.Name}.Exec"); + context.Blueprint.AddConnection(new BlueprintConnection + { + SourceNodeId = branch.Id, + SourcePinId = falsePin.Id, + TargetNodeId = firstFalseNode.Id, + TargetPinId = firstExecIn.Id + }); + } + } + } + } + } + } + + private void ProcessLoopNodeConnectionsInMain(LoopNode loop, FlowControlStatement flowCtrl, ConversionContext context) + { + // ✅ 使用已创建的 loop 节点,不重新创建 + double x = 350; + double targetY = 300; + + // ✅ 处理 LoopBody + if (!string.IsNullOrEmpty(flowCtrl.TrueBlockName)) + { + var loopBodyBlock = context.Script.GetBlockByName(flowCtrl.TrueBlockName); + if (loopBodyBlock != null) + { + // ✅ 同时注册 TrueBlockName 和 ParentBlockName,以便 LoopBodyEnd 查找 + context.LoopNodesByParentBlock[flowCtrl.TrueBlockName] = loop; + if (!string.IsNullOrEmpty(flowCtrl.LoopBodyEndReturnTo)) + { + context.LoopNodesByParentBlock[flowCtrl.LoopBodyEndReturnTo] = loop; + } + + var firstLoopNode = ProcessBlockRecursive(loopBodyBlock, context, x, targetY); + if (firstLoopNode != null) + { + var loopBodyPin = loop.OutputPins.FirstOrDefault(p => p.Name == "LoopBody"); + var firstExecIn = firstLoopNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); + if (loopBodyPin != null && firstExecIn != null) + { + Console.WriteLine($"[DEBUG] ProcessLoopNodeConnectionsInMain: Creating {loop.Name}.LoopBody -> {firstLoopNode.Name}.Exec"); + context.Blueprint.AddConnection(new BlueprintConnection + { + SourceNodeId = loop.Id, + SourcePinId = loopBodyPin.Id, + TargetNodeId = firstLoopNode.Id, + TargetPinId = firstExecIn.Id + }); + } + } + } + } + + // 处理 LoopEnd + if (!string.IsNullOrEmpty(flowCtrl.FalseBlockName)) + { + var afterLoopBlock = context.Script.GetBlockByName(flowCtrl.FalseBlockName); + if (afterLoopBlock != null) + { + var firstAfterLoopNode = ProcessBlockRecursive(afterLoopBlock, context, x, targetY + 200); + if (firstAfterLoopNode != null) + { + var loopEndPin = loop.OutputPins.FirstOrDefault(p => p.Name == "LoopEnd"); + var firstExecIn = firstAfterLoopNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); + if (loopEndPin != null && firstExecIn != null) + { + Console.WriteLine($"[DEBUG] ProcessLoopNodeConnectionsInMain: Creating {loop.Name}.LoopEnd -> {firstAfterLoopNode.Name}.Exec"); + context.Blueprint.AddConnection(new BlueprintConnection + { + SourceNodeId = loop.Id, + SourcePinId = loopEndPin.Id, + TargetNodeId = firstAfterLoopNode.Id, + TargetPinId = firstExecIn.Id + }); + } + } + } + } + + // 处理 LoopBodyEndReturnTo + if (!string.IsNullOrEmpty(flowCtrl.LoopBodyEndReturnTo)) + { + context.LoopNodesByParentBlock[flowCtrl.LoopBodyEndReturnTo] = loop; + } + } + + private BlueprintNode? ProcessStatement(BlockStatement statement, ConversionContext context, double x, double y) + { + Console.WriteLine($"[DEBUG] ProcessStatement: {statement.GetType().Name}"); + switch (statement) + { + case FlowControlStatement flowCtrl: + Console.WriteLine($"[DEBUG] ProcessStatement: FlowControlStatement ControlType={flowCtrl.ControlType}, TrueBlock={flowCtrl.TrueBlockName}, FalseBlock={flowCtrl.FalseBlockName}, SourceCode={flowCtrl.SourceCode}"); + // ✅ NextBlock = Branch/Loop(...) 需要创建节点!不能跳过 + // 因为 Branch/Loop 节点有输出引脚需要连接到子块 + if ((flowCtrl.ControlType == FlowControlType.Branch || flowCtrl.ControlType == FlowControlType.Loop) && + !string.IsNullOrEmpty(flowCtrl.TrueBlockName) && + !string.IsNullOrEmpty(flowCtrl.FalseBlockName)) + { + // ✅ 对于 Loop 节点,检查是否已存在(避免重复创建) + if (flowCtrl.ControlType == FlowControlType.Loop && + context.CurrentParentBlock != null && + context.LoopNodesByParentBlock.TryGetValue(context.CurrentParentBlock, out var existingLoop)) + { + Console.WriteLine($"[DEBUG] ProcessStatement: Reusing existing Loop node for {context.CurrentParentBlock}"); + context.LastProcessedNode = existingLoop; + return existingLoop; + } + + Console.WriteLine($"[DEBUG] ProcessStatement: Creating {flowCtrl.ControlType} node for NextBlock = {flowCtrl.ControlType}(...)"); + var flowNode = ProcessFlowControlStatement(flowCtrl, context, x, y); + if (flowNode != null) + { + context.LastProcessedNode = flowNode; + } + return flowNode; + } + if (flowCtrl.ControlType == FlowControlType.LoopBodyEnd) + { + HandleLoopBodyEnd(flowCtrl, context); + return null; + } + var otherFlowNode = ProcessFlowControlStatement(flowCtrl, context, x, y); + if (otherFlowNode != null) + { + context.LastProcessedNode = otherFlowNode; + } + return otherFlowNode; + + case ExpressionStatement expr: + if (expr.Expression.StartsWith("NextBlock = ")) + return null; + var actionNode = CreateActionNodeFromExpression(expr.Expression, context); + if (actionNode != null) + { + context.LastProcessedNode = actionNode; + } + return actionNode; + + default: + return null; + } + } + + private void HandleLoopBodyEnd(FlowControlStatement flowCtrl, ConversionContext context) + { + if (!string.IsNullOrEmpty(flowCtrl.LoopBodyEndReturnTo)) + { + if (context.LoopNodesByParentBlock.TryGetValue(flowCtrl.LoopBodyEndReturnTo, out var loopNode)) + { + var lastNode = context.LastProcessedNode; + + if (lastNode != null) + { + // 查找 BLE 节点(通过 Loop.Condition 的数据连接,或通过 VariableSources 中条件变量的来源) + var loopConditionIn = loopNode.InputPins.FirstOrDefault(p => p.Name == "Condition"); + BlueprintNode? bleNode = null; + + // 方案1:通过 Loop.Condition 的数据连接查找 + if (loopConditionIn != null) + { + var conditionConn = context.Blueprint.Connections + .FirstOrDefault(c => c.TargetNodeId == loopNode.Id && + c.TargetPinId == loopConditionIn.Id); + if (conditionConn != null) + { + bleNode = context.Blueprint.Nodes.FirstOrDefault(n => n.Id == conditionConn.SourceNodeId); + } + } + + // 方案2:通过 VariableSources 中条件变量的来源查找 + if (bleNode == null && !string.IsNullOrEmpty(flowCtrl.ConditionExpression)) + { + var conditionVarName = flowCtrl.ConditionExpression.Trim(); + var source = context.VariableSources.Values + .FirstOrDefault(s => s.PubVarName == conditionVarName); + if (source != null) + { + bleNode = source.Node; + } + } + + // 如果找到了 BLE 节点,建立完整的执行流回连 + if (bleNode != null) + { + var lastExecOut = lastNode.OutputPins.FirstOrDefault(p => p.Name == "Exec"); + var bleExecIn = bleNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); + var bleExecOut = bleNode.OutputPins.FirstOrDefault(p => p.Name == "Exec"); + var loopExecIn = loopNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); + + // lastNode.Exec -> BLE.Exec + if (lastExecOut != null && bleExecIn != null && lastNode.Id != bleNode.Id) + { + var hasConnection = context.Blueprint.Connections + .Any(c => c.SourceNodeId == lastNode.Id && + c.SourcePinId == lastExecOut.Id && + c.TargetNodeId == bleNode.Id && + c.TargetPinId == bleExecIn.Id); + if (!hasConnection) + { + context.Blueprint.AddConnection(new BlueprintConnection + { + SourceNodeId = lastNode.Id, + SourcePinId = lastExecOut.Id, + TargetNodeId = bleNode.Id, + TargetPinId = bleExecIn.Id + }); + } + } + + // BLE.Exec -> Loop.Exec + if (bleExecOut != null && loopExecIn != null) + { + var hasConnection = context.Blueprint.Connections + .Any(c => c.SourceNodeId == bleNode.Id && + c.SourcePinId == bleExecOut.Id && + c.TargetNodeId == loopNode.Id && + c.TargetPinId == loopExecIn.Id); + if (!hasConnection) + { + context.Blueprint.AddConnection(new BlueprintConnection + { + SourceNodeId = bleNode.Id, + SourcePinId = bleExecOut.Id, + TargetNodeId = loopNode.Id, + TargetPinId = loopExecIn.Id + }); + } + } + } + } + + context.LoopNodesByParentBlock[flowCtrl.LoopBodyEndReturnTo] = loopNode; + } + } + } + private BlueprintNode? ProcessFlowControlStatement(FlowControlStatement flowCtrl, ConversionContext context, double x, double y) + { + var node = CreateControlFlowNode(flowCtrl, context); + if (node == null) return null; + + node.X = x; + node.Y = y; + + // ✅ 不在这里添加到 blueprint,由调用者(ProcessStatement)负责添加 + // 这样可以避免重复添加 + + if (!string.IsNullOrEmpty(flowCtrl.ConditionExpression)) + { + CreateDataConnectionsForExpression(flowCtrl.ConditionExpression, node, context); + } + + double targetY = y + 150; + + switch (flowCtrl.ControlType) + { + case FlowControlType.Branch: + // ✅ 不在这里处理子块连接,由 ProcessBlockRecursive 通过 NextBlock 属性统一处理 + // 这样可以避免重复处理和 visited 问题 + Console.WriteLine($"[DEBUG] ProcessFlowControlStatement: Branch node created, TrueBlock={flowCtrl.TrueBlockName}, FalseBlock={flowCtrl.FalseBlockName}"); + break; + + case FlowControlType.Loop: + // ✅ 不在这里处理子块连接,由 ProcessBlockRecursive 通过 NextBlock 属性统一处理 + Console.WriteLine($"[DEBUG] ProcessFlowControlStatement: Loop node created, LoopBody={flowCtrl.TrueBlockName}, LoopEnd={flowCtrl.FalseBlockName}"); + break; + } + + return node; + } + + private BlueprintNode? ProcessBlockRecursive(BlockDefinition block, ConversionContext context, double x, double y) + { + Console.WriteLine($"[DEBUG] ProcessBlockRecursive: block={block.Name}, type={block.Type}, statements={block.Statements.Count}"); + + // 如果块已经访问过,返回第一个节点用于建立连接(而不是null) + if (context.VisitedBlocks.Contains(block.Name)) + { + var cachedFirstNode = context.BlockFirstNodes.GetValueOrDefault(block.Name); + Console.WriteLine($"[DEBUG] ProcessBlockRecursive: block {block.Name} ALREADY VISITED, returning {(cachedFirstNode != null ? cachedFirstNode.Name : "null")}"); + return cachedFirstNode; + } + context.VisitedBlocks.Add(block.Name); + + var nodes = new List(); + var currentY = y; + + Console.WriteLine($"[DEBUG] ProcessBlockRecursive: Processing {block.Statements.Count} statements for block {block.Name}"); + // 首先处理所有可执行语句节点 + foreach (var statement in block.Statements) + { + Console.WriteLine($"[DEBUG] ProcessBlockRecursive: Processing statement: {statement.GetType().Name}"); + var node = ProcessStatement(statement, context, x, currentY); + Console.WriteLine($"[DEBUG] ProcessBlockRecursive: ProcessStatement returned: {node?.Name} ({node?.Id})"); + if (node != null) + { + // ✅ 检查节点是否已经存在于 Blueprint 中,避免重复添加 + if (!context.Blueprint.Nodes.Any(n => n.Id == node.Id)) + { + Console.WriteLine($"[DEBUG] ProcessBlockRecursive: Adding node {node.Name} ({node.Id}) to blueprint"); + context.Blueprint.AddNode(node); + } + else + { + Console.WriteLine($"[DEBUG] ProcessBlockRecursive: Node {node.Name} ({node.Id}) already in blueprint, skipping"); + } + nodes.Add(node); + currentY += 120; + + if (context.PendingNodesForExecChain.Count > 0) + { + var parentNodeIndex = nodes.Count - 1; + foreach (var pendingNode in context.PendingNodesForExecChain) + { + if (!nodes.Any(n => n.Id == pendingNode.Id)) + { + nodes.Insert(parentNodeIndex + 1, pendingNode); + parentNodeIndex++; + } + } + context.PendingNodesForExecChain.Clear(); + } + } + } + Console.WriteLine($"[DEBUG] ProcessBlockRecursive: After processing statements, nodes.Count = {nodes.Count}"); + + // 如果块只有路由语句(nodes为空),仍然需要处理路由以建立连接 + if (nodes.Count == 0 && block.Statements.Count > 0) + { + var firstStatement = block.Statements[0]; + if (firstStatement is FlowControlStatement flowCtrl && + (flowCtrl.ControlType == FlowControlType.Branch || flowCtrl.ControlType == FlowControlType.Loop)) + { + // 这是一个纯路由块,需要处理路由语句以建立连接 + Console.WriteLine($"[DEBUG] ProcessBlockRecursive: Processing routing-only block {block.Name}"); + var routingNode = ProcessFlowControlStatement(flowCtrl, context, x, y); + if (routingNode != null) + { + context.Blueprint.AddNode(routingNode); + nodes.Add(routingNode); + Console.WriteLine($"[DEBUG] ProcessBlockRecursive: Added routing node {routingNode.Name} ({routingNode.Id}) to nodes list"); + } + } + } + + LinkNodesWithExec(nodes, context.Blueprint); + + // ✅ 标记为已访问 + context.VisitedBlocks.Add(block.Name); + + // ✅ 处理 Branch/Loop 节点与子块的连接 + var flowNode = nodes.LastOrDefault(n => n is BranchNode || n is LoopNode); + if (flowNode != null) + { + Console.WriteLine($"[DEBUG] ProcessBlockRecursive: Processing flow node connections for {block.Name}"); + if (flowNode is BranchNode branch) + { + ProcessBranchNodeConnections(branch, block, context, x, y); + } + else if (flowNode is LoopNode loop) + { + ProcessLoopNodeConnections(loop, block, context, x, y); + } + } + + // 处理跨块连接 + if (!string.IsNullOrEmpty(block.NextBlockName) && + block.Type != BlockType.LoopBlock) + { + var nextBlock = context.Script.GetBlockByName(block.NextBlockName); + if (nextBlock != null && !context.VisitedBlocks.Contains(nextBlock.Name)) + { + if (nodes.Count > 0) + { + var lastNode = nodes.Last(); + var nextFirstNode = ProcessBlockRecursive(nextBlock, context, x + 300, y); + if (nextFirstNode != null) + { + var lastExecOut = lastNode.OutputPins.FirstOrDefault(p => p.Name == "Exec"); + var nextExecIn = nextFirstNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); + + // ✅ 验证是有效的 Exec 连接 + if (lastExecOut == null || lastExecOut.Direction != PinDirection.Output) + { + Log.Warning("Cannot create cross-block Exec connection: {Node}.Exec is not a valid output pin", + lastNode.Name); + } + else if (lastNode.Id == nextFirstNode.Id) + { + Log.Warning("Skipping self-loop cross-block Exec connection"); + } + else if (lastExecOut != null && nextExecIn != null) + { + context.Blueprint.AddConnection(new BlueprintConnection + { + SourceNodeId = lastNode.Id, + SourcePinId = lastExecOut.Id, + TargetNodeId = nextFirstNode.Id, + TargetPinId = nextExecIn.Id + }); + } + } + } + } + } + + // 存储块的第一个节点,以便后续已访问块的连接 + var firstNode = nodes.FirstOrDefault(); + if (firstNode != null) + { + context.BlockFirstNodes[block.Name] = firstNode; + } + + return firstNode; + } + + private void ProcessBranchNodeConnections(BranchNode branch, BlockDefinition block, ConversionContext context, double x, double y) + { + var callId = Guid.NewGuid().ToString().Substring(0, 8); + Console.WriteLine($"[DEBUG] ProcessBranchNodeConnections ENTER: callId={callId}, branch={branch.Id.Substring(0,8)}, block={block.Name}"); + // 找到对应的 FlowControlStatement + var flowCtrl = block.Statements.FirstOrDefault(s => s is FlowControlStatement) as FlowControlStatement; + if (flowCtrl == null) return; + + double targetY = y + 150; + + // 处理 True 分支 + if (!string.IsNullOrEmpty(flowCtrl.TrueBlockName)) + { + var trueBlock = context.Script.GetBlockByName(flowCtrl.TrueBlockName); + // ✅ 如果 trueBlock 已经访问过,跳过(避免重复创建连接) + if (trueBlock != null && context.VisitedBlocks.Contains(trueBlock.Name)) + { + Console.WriteLine($"[DEBUG] ProcessBranchNodeConnections[{callId}]: trueBlock {trueBlock.Name} already visited, skipping"); + } + else if (trueBlock != null) + { + var firstTrueNode = ProcessBlockRecursive(trueBlock, context, x + 300, targetY); + if (firstTrueNode != null && firstTrueNode.Id != branch.Id) + { + var truePin = branch.OutputPins.FirstOrDefault(p => p.Name == "True"); + var firstExecIn = firstTrueNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); + if (truePin != null && firstExecIn != null) + { + Console.WriteLine($"[DEBUG] ProcessBranchNodeConnections: Creating {branch.Name}.True -> {firstTrueNode.Name}.Exec"); + context.Blueprint.AddConnection(new BlueprintConnection + { + SourceNodeId = branch.Id, + SourcePinId = truePin.Id, + TargetNodeId = firstTrueNode.Id, + TargetPinId = firstExecIn.Id + }); + } + } + } + } + + // 处理 False 分支 + if (!string.IsNullOrEmpty(flowCtrl.FalseBlockName)) + { + var falseBlock = context.Script.GetBlockByName(flowCtrl.FalseBlockName); + if (falseBlock != null) + { + // ✅ 无论块是否已访问,都尝试获取第一个节点并建立连接 + // 对于已访问的块,从 BlockFirstNodes 获取;对于未访问的块,调用 ProcessBlockRecursive + BlueprintNode? firstFalseNode; + if (context.VisitedBlocks.Contains(falseBlock.Name)) + { + firstFalseNode = context.BlockFirstNodes.GetValueOrDefault(falseBlock.Name); + Console.WriteLine($"[DEBUG] ProcessBranchNodeConnections: falseBlock {falseBlock.Name} already visited, got first node from cache"); + } + else + { + firstFalseNode = ProcessBlockRecursive(falseBlock, context, x + 300, targetY + 200); + } + + Console.WriteLine($"[DEBUG] ProcessBranchNodeConnections: firstFalseNode = {firstFalseNode?.Name} ({firstFalseNode?.Id?.Substring(0,8) ?? "null"}), branch.Id = {branch.Id.Substring(0,8)}"); + + if (firstFalseNode == null) + { + Console.WriteLine($"[DEBUG] ProcessBranchNodeConnections: firstFalseNode is null, skipping"); + } + else if (firstFalseNode.Id == branch.Id) + { + Console.WriteLine($"[DEBUG] ProcessBranchNodeConnections: Skipping self-loop"); + } + else if (!context.Blueprint.Nodes.Any(n => n.Id == firstFalseNode.Id)) + { + Console.WriteLine($"[DEBUG] ProcessBranchNodeConnections: firstFalseNode NOT FOUND in Blueprint, skipping"); + } + else + { + // ✅ 如果目标是路由块(Branch/Loop),获取其实际可连接的子节点 + var actualTarget = GetFirstConnectableNode(firstFalseNode, context); + var falsePin = branch.OutputPins.FirstOrDefault(p => p.Name == "False"); + var actualExecIn = actualTarget?.InputPins.FirstOrDefault(p => p.Name == "Exec"); + + if (falsePin != null && actualExecIn != null && actualTarget != null && actualTarget.Id != branch.Id) + { + Console.WriteLine($"[DEBUG] ProcessBranchNodeConnections: Creating {branch.Name}.False -> {actualTarget.Name}.Exec"); + context.Blueprint.AddConnection(new BlueprintConnection + { + SourceNodeId = branch.Id, + SourcePinId = falsePin.Id, + TargetNodeId = actualTarget.Id, + TargetPinId = actualExecIn.Id + }); + } + } + } + } + + Console.WriteLine($"[DEBUG] ProcessBranchNodeConnections EXIT: callId={callId}, branch={branch.Id.Substring(0,8)}"); + } + + /// + /// 获取节点的可连接子节点。如果节点本身有来自外部(跨块)的 Exec 输入引脚连接,直接返回; + /// 如果是路由块(Branch/Loop)且其 Exec 输入只被内部连接,使用,则需要找到其连接的目标节点。 + /// + private BlueprintNode? GetFirstConnectableNode(BlueprintNode node, ConversionContext context) + { + // 如果节点本身有来自外部的 Exec 输入引脚连接,直接返回 + var execInputPin = node.InputPins.FirstOrDefault(p => p.Name == "Exec"); + if (execInputPin != null) + { + // 检查是否有从其他节点到这个节点 Exec 输入的连接(外部连接) + var hasExternalConnection = context.Blueprint.Connections + .Any(c => c.TargetNodeId == node.Id && + c.TargetPinId == execInputPin.Id && + c.SourceNodeId != node.Id); + if (hasExternalConnection) + { + return node; + } + } + + // 如果是 Branch 节点,找到其 True 输出连接的目标节点 + if (node is BranchNode branch) + { + var trueConn = context.Blueprint.Connections + .FirstOrDefault(c => c.SourceNodeId == branch.Id && + branch.OutputPins.Any(p => p.Id == c.SourcePinId && p.Name == "True")); + if (trueConn != null) + { + var targetNode = context.Blueprint.GetNodeById(trueConn.TargetNodeId); + if (targetNode != null) + { + // 递归获取实际可连接的节点 + return GetFirstConnectableNode(targetNode, context); + } + } + } + + // 如果是 Loop 节点,找到其 LoopBody 输出连接的目标节点 + if (node is LoopNode loop) + { + var loopBodyConn = context.Blueprint.Connections + .FirstOrDefault(c => c.SourceNodeId == loop.Id && + loop.OutputPins.Any(p => p.Id == c.SourcePinId && p.Name == "LoopBody")); + if (loopBodyConn != null) + { + var targetNode = context.Blueprint.GetNodeById(loopBodyConn.TargetNodeId); + if (targetNode != null) + { + return GetFirstConnectableNode(targetNode, context); + } + } + } + + return node; // 回退 + } + + private void ProcessLoopNodeConnections(LoopNode loop, BlockDefinition block, ConversionContext context, double x, double y) + { + // 找到对应的 FlowControlStatement + var flowCtrl = block.Statements.FirstOrDefault(s => s is FlowControlStatement) as FlowControlStatement; + if (flowCtrl == null) return; + + double targetY = y + 150; + + // 处理 LoopBody + if (!string.IsNullOrEmpty(flowCtrl.TrueBlockName)) + { + var loopBodyBlock = context.Script.GetBlockByName(flowCtrl.TrueBlockName); + if (loopBodyBlock != null) + { + // 注册 Loop 节点以便 LoopBodyEnd 引用 + context.LoopNodesByParentBlock[flowCtrl.TrueBlockName] = loop; + + var firstLoopNode = ProcessBlockRecursive(loopBodyBlock, context, x + 300, targetY); + if (firstLoopNode != null) + { + var loopBodyPin = loop.OutputPins.FirstOrDefault(p => p.Name == "LoopBody"); + var firstExecIn = firstLoopNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); + if (loopBodyPin != null && firstExecIn != null) + { + Console.WriteLine($"[DEBUG] ProcessLoopNodeConnections: Creating {loop.Name}.LoopBody -> {firstLoopNode.Name}.Exec"); + context.Blueprint.AddConnection(new BlueprintConnection + { + SourceNodeId = loop.Id, + SourcePinId = loopBodyPin.Id, + TargetNodeId = firstLoopNode.Id, + TargetPinId = firstExecIn.Id + }); + } + } + } + } + + // 处理 LoopEnd + if (!string.IsNullOrEmpty(flowCtrl.FalseBlockName)) + { + var afterLoopBlock = context.Script.GetBlockByName(flowCtrl.FalseBlockName); + if (afterLoopBlock != null) + { + var firstAfterLoopNode = ProcessBlockRecursive(afterLoopBlock, context, x + 300, targetY + 200); + if (firstAfterLoopNode != null) + { + var loopEndPin = loop.OutputPins.FirstOrDefault(p => p.Name == "LoopEnd"); + var firstExecIn = firstAfterLoopNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); + if (loopEndPin != null && firstExecIn != null) + { + Console.WriteLine($"[DEBUG] ProcessLoopNodeConnections: Creating {loop.Name}.LoopEnd -> {firstAfterLoopNode.Name}.Exec"); + context.Blueprint.AddConnection(new BlueprintConnection + { + SourceNodeId = loop.Id, + SourcePinId = loopEndPin.Id, + TargetNodeId = firstAfterLoopNode.Id, + TargetPinId = firstExecIn.Id + }); + } + } + } + } + + // 处理 LoopBodyEndReturnTo + if (!string.IsNullOrEmpty(flowCtrl.LoopBodyEndReturnTo)) + { + context.LoopNodesByParentBlock[flowCtrl.LoopBodyEndReturnTo] = loop; + } + } + + private BlueprintNode? CreateControlFlowNode(FlowControlStatement statement, ConversionContext context) + { + switch (statement.ControlType) + { + case FlowControlType.Branch: + return new BranchNode(); + case FlowControlType.Loop: + return new LoopNode(); + case FlowControlType.Break: + return new BreakNode(); + default: + return null; + } + } + + private static string GetMethodNameFromExpression(InvocationExpressionSyntax invoke) + { + if (invoke.Expression is IdentifierNameSyntax id) + return id.Identifier.Text; + if (invoke.Expression is GenericNameSyntax generic) + return generic.Identifier.Text; + if (invoke.Expression is MemberAccessExpressionSyntax member) + return member.Name.Identifier.Text; + return string.Empty; + } + + private BlueprintNode? CreateActionNodeFromExpression(string expression, ConversionContext context) + { + var parsed = TryParseInvocationExpression(expression); + if (parsed == null) + return null; + + var (funcName, args, assignment) = parsed.Value; + + if (funcName == "Get") + { + var varName = args?.Arguments.FirstOrDefault()?.Expression.ToString() ?? string.Empty; + + // ✅ 始终为 Get 创建独立的 GetNode + // 注意:不在此处添加到 Blueprint,由 ProcessBlockRecursive 统一添加 + var getNode = new GetNode + { + VarName = varName, + Name = $"Get:{varName}" + }; + Log.Debug("Created GetNode: VarName={VarName}, Id={NodeId}", varName, getNode.Id); + + var getValuePin = getNode.OutputPins.First(p => p.Name == "Value"); + + // ✅ 如果是赋值给 PubVar,注册数据源(使用唯一 key) + if (assignment != null) + { + var assignedVarName = assignment.Left.ToString(); + if (assignedVarName != "_") + { + var isPubVar = context.Script.PubVarBlock?.Variables + .Any(v => v.Name == assignedVarName) == true; + // ✅ 使用唯一 key(包含 Guid),避免嵌套复用 + context.VariableSources[$"__get_{assignedVarName}_{Guid.NewGuid():N}__"] = new VariableSource + { + Node = getNode, + Pin = getValuePin, + PubVarName = isPubVar ? assignedVarName : null + }; + Log.Debug("Registered GetNode output as VariableSource: {VarName}, IsPubVar={IsPubVar}", + assignedVarName, isPubVar); + } + } + + return getNode; // Return immediately — do NOT fall through to the CallNode block below + } + + if (funcName == "Set") + { + var argList = args?.Arguments.ToList() ?? new List(); + if (argList.Count >= 2) + { + var varName = argList[0].Expression.ToString(); + var valueExpr = argList[1].Expression; // ✅ 使用ExpressionSyntax而非ToString() + // 注意:不在此处添加到 Blueprint,由 ProcessBlockRecursive 统一添加 + var setNode = new SetNode + { + VarName = varName, + Name = $"Set:{varName}" + }; + Log.Debug("Created SetNode: VarName={VarName}, Id={NodeId}", varName, setNode.Id); + + // ✅ 获取Set节点的Value输入引脚 + var setValuePin = setNode.InputPins.FirstOrDefault(p => p.Name == "Value"); + if (setValuePin != null && valueExpr != null) + { + // ✅ 使用ProcessArgumentExpression处理嵌套函数调用 + ProcessArgumentExpression(valueExpr, setValuePin, setNode, context); + } + + return setNode; // Return immediately — do NOT fall through to the CallNode block below + } + return null; + } + + if (funcName == "Print") + { + var printNode = new PrintNode(); + var arg = args?.Arguments.FirstOrDefault()?.Expression.ToString() ?? string.Empty; + CreateDataConnectionsForExpression(arg, printNode, context); + return printNode; + } + + if (funcName == "Pause") + { + var pauseNode = new PauseNode(); + var arg = args?.Arguments.FirstOrDefault()?.Expression.ToString() ?? string.Empty; + CreateDataConnectionsForExpression(arg, pauseNode, context); + return pauseNode; + } + + if (assignment != null) + { + var varName = assignment.Left.ToString(); + + if (funcName is "Loop" or "Branch" or "LoopBodyEnd" or "Break") + { + Log.Debug("Skipped creating CallNode for flow control function: {FuncName}", funcName); + return null; + } + + // ✅ 检查是否应该复用已有的 VariableSource + // 如果 varName 已经有一个来源(相同函数的调用),则复用;否则创建新的 + var existingSource = context.VariableSources.Values + .FirstOrDefault(s => s.PubVarName == varName); + + BlueprintNode callNode; + if (existingSource != null) + { + // ✅ 复用已有的源节点 + callNode = existingSource.Node; + Log.Debug("Reused existing source node {NodeName} for {VarName}", callNode.Name, varName); + } + else + { + // ✅ 创建新的 Call 节点 + var isHelper = context.Script.HelperFunctions.Any(h => h.Name == funcName); + if (isHelper) + callNode = new CallHelperNode { HelperFunctionName = funcName, Name = $"Helper:{funcName}" }; + else + callNode = new CallNode { FunctionName = funcName, Name = $"Call:{funcName}" }; + + AddParameterPins(callNode, args?.Arguments); + CreateDataConnectionsForArguments(args?.Arguments, callNode, context); + + // ✅ 注册新的数据源 + var returnPin = callNode.OutputPins.FirstOrDefault(p => p.Name == "Return"); + if (returnPin != null) + { + // Check if varName (assignment target) is a PubVar + var isPubVar = context.Script.PubVarBlock?.Variables + .Any(v => v.Name == varName) == true; + context.VariableSources[varName] = new VariableSource + { + Node = callNode, + Pin = returnPin, + PubVarName = isPubVar ? varName : null // ✅ Set PubVarName for PubVar + }; + } + } + + return callNode; + } + + if (funcName is "Loop" or "Branch" or "LoopBodyEnd" or "Break") + { + Log.Debug("Skipped creating CallNode for flow control/builtin function: {FuncName}", funcName); + return null; + } + + BlueprintNode callNode2; + var isHelper2 = context.Script.HelperFunctions.Any(h => h.Name == funcName); + if (isHelper2) + callNode2 = new CallHelperNode { HelperFunctionName = funcName, Name = $"Helper:{funcName}" }; + else + callNode2 = new CallNode { FunctionName = funcName, Name = $"Call:{funcName}" }; + + AddParameterPins(callNode2, args?.Arguments); + CreateDataConnectionsForArguments(args?.Arguments, callNode2, context); + + return callNode2; + } + + private (string? funcName, ArgumentListSyntax? args, AssignmentExpressionSyntax? assignment)? TryParseInvocationExpression(string expression) + { + var wrappedCode = $"_ = {expression};"; + var syntaxTree = CSharpSyntaxTree.ParseText(wrappedCode, cancellationToken: CancellationToken.None); + var root = syntaxTree.GetCompilationUnitRoot(); + + var globalStmt = root.Members.FirstOrDefault() as GlobalStatementSyntax; + var stmt = globalStmt?.Statement as ExpressionStatementSyntax; + + if (stmt?.Expression is AssignmentExpressionSyntax outerAssignment) + { + ExpressionSyntax rightExpr = outerAssignment.Right; + // Track the innermost assignment that has a non-underscore variable on the left + AssignmentExpressionSyntax? innermostAssignment = null; + while (rightExpr is AssignmentExpressionSyntax nestedAssignment) + { + innermostAssignment = nestedAssignment; + rightExpr = nestedAssignment.Right; + } + + if (rightExpr is InvocationExpressionSyntax invoke) + { + var methodName = GetMethodNameFromExpression(invoke); + // Use the innermost assignment if available, otherwise the outermost + var assignmentToReturn = innermostAssignment ?? outerAssignment; + return (methodName, invoke.ArgumentList, assignmentToReturn); + } + } + else if (stmt?.Expression is InvocationExpressionSyntax directInvoke) + { + var methodName = GetMethodNameFromExpression(directInvoke); + return (methodName, directInvoke.ArgumentList, null); + } + return null; + } + + private void AddParameterPins(BlueprintNode node, SeparatedSyntaxList? arguments) + { + if (!arguments.HasValue || arguments.Value.Count == 0) return; + + foreach (var arg in arguments.Value) + { + node.InputPins.Add(new BlueprintPin + { + Name = $"param{node.InputPins.Count}", + Direction = PinDirection.Input, + Type = PinType.Any + }); + } + } + + private void ProcessLoopBlock(BlockDefinition block, ConversionContext context) + { + if (context.VisitedBlocks.Contains(block.Name)) return; + context.VisitedBlocks.Add(block.Name); + + // ✅ 设置当前父块名称,以便复用 Loop 节点 + var parentBlockName = block.ParentBlockName ?? block.Name; + context.CurrentParentBlock = parentBlockName; + + var nodes = new List(); + var currentY = 50.0; + + foreach (var statement in block.Statements) + { + var node = ProcessStatement(statement, context, 50, currentY); + if (node != null) + { + context.Blueprint.AddNode(node); + nodes.Add(node); + currentY += 120; + } + } + + LinkNodesWithExec(nodes, context.Blueprint); + + foreach (var statement in block.Statements) + { + if (statement is FlowControlStatement flowCtrl) + { + if (!string.IsNullOrEmpty(flowCtrl.TrueBlockName)) + { + var trueBlock = context.Script.GetBlockByName(flowCtrl.TrueBlockName); + if (trueBlock != null) + ProcessBlockRecursive(trueBlock, context, 350, 50); + } + if (!string.IsNullOrEmpty(flowCtrl.FalseBlockName)) + { + var falseBlock = context.Script.GetBlockByName(flowCtrl.FalseBlockName); + if (falseBlock != null) + ProcessBlockRecursive(falseBlock, context, 350, 200); + } + } + } + } + + private void LinkNodesWithExec(List nodes, Contract.Workflow.Blueprint blueprint) + { + Console.WriteLine($"[DEBUG] === LinkNodesWithExec called with {nodes.Count} nodes: {string.Join(", ", nodes.Select(n => $"{n.Name}({n.Id.Substring(0,8)})"))}"); + + for (int i = 0; i < nodes.Count - 1; i++) + { + var currentNode = nodes[i]; + var nextNode = nodes[i + 1]; + + // ✅ 跳过自环连接 + if (currentNode.Id == nextNode.Id) + { + Console.WriteLine($"[DEBUG] LinkNodesWithExec: Skipping self-loop for {currentNode.Name} ({currentNode.Id.Substring(0,8)})"); + continue; + } + + // ✅ 获取 currentNode 的 Exec 输出引脚 + var currentExecOut = currentNode.OutputPins.FirstOrDefault(p => p.Name == "Exec"); + + // ✅ Branch 和 Loop 节点没有 Exec 输出引脚,跳过 + if (currentExecOut == null) + { + Log.Debug("Skipping Exec link: {Node} has no Exec output pin", currentNode.Name); + continue; + } + + // ✅ 确保 currentExecOut 是 OUTPUT 类型的引脚 + if (currentExecOut.Direction != PinDirection.Output) + { + Log.Warning("Cannot create Exec connection: {Node}.Exec is not a valid output pin", currentNode.Name); + continue; + } + + // ✅ 获取 nextNode 的 Exec 输入引脚 + var nextExecIn = nextNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); + if (nextExecIn == null) + { + Log.Warning("Cannot create Exec connection: {Node} has no Exec input pin", nextNode.Name); + continue; + } + + Console.WriteLine($"[DEBUG] LinkNodesWithExec: Creating {currentNode.Name}.{currentExecOut.Name} -> {nextNode.Name}.{nextExecIn.Name}"); + + blueprint.AddConnection(new BlueprintConnection + { + SourceNodeId = currentNode.Id, + SourcePinId = currentExecOut.Id, + TargetNodeId = nextNode.Id, + TargetPinId = nextExecIn.Id + }); + } + } + + private void LayoutNodes(Contract.Workflow.Blueprint blueprint) + { + var depths = new Dictionary(); + var visited = new HashSet(); + + var entry = blueprint.Nodes.FirstOrDefault(n => n.NodeType == BlueprintNodeType.Entry); + if (entry != null) + { + depths[entry.Id] = 0; + CalculateDepths(blueprint, entry, depths, visited); + } + + var depthGroups = new Dictionary>(); + foreach (var node in blueprint.Nodes) + { + if (depths.TryGetValue(node.Id, out var depth)) + { + if (!depthGroups.ContainsKey(depth)) + depthGroups[depth] = new List(); + depthGroups[depth].Add(node); + } + } + + foreach (var group in depthGroups) + { + var depth = group.Key; + var nodes = group.Value; + for (int i = 0; i < nodes.Count; i++) + { + nodes[i].X = 50 + depth * 300; + nodes[i].Y = 50 + i * 120; + } + } + } + + private void CalculateDepths(Contract.Workflow.Blueprint blueprint, BlueprintNode node, Dictionary depths, HashSet visited) + { + if (visited.Contains(node.Id)) return; + visited.Add(node.Id); + + var outConnections = blueprint.GetConnectionsFrom(node.Id); + foreach (var conn in outConnections) + { + if (blueprint.GetNodeById(conn.TargetNodeId) is { } targetNode) + { + var currentDepth = depths[node.Id]; + if (!depths.ContainsKey(targetNode.Id) || depths[targetNode.Id] < currentDepth + 1) + { + depths[targetNode.Id] = currentDepth + 1; + } + CalculateDepths(blueprint, targetNode, depths, visited); + } + } + } + + private bool IsVariableReference(string token) + { + if (string.IsNullOrWhiteSpace(token)) return false; + if (bool.TryParse(token, out _)) return false; + if (int.TryParse(token, out _) || double.TryParse(token, out _)) return false; + if (token.StartsWith("\"") && token.EndsWith("\"")) return false; + if (token.StartsWith("'") && token.EndsWith("'")) return false; + if (token.Contains('(')) return false; + return System.Text.RegularExpressions.Regex.IsMatch(token, @"^[a-zA-Z_]\w*$"); + } + + private void CreateDataConnectionsForExpression(string expression, BlueprintNode targetNode, ConversionContext context) + { + var wrappedCode = $"_ = {expression};"; + var syntaxTree = CSharpSyntaxTree.ParseText(wrappedCode, cancellationToken: CancellationToken.None); + var root = syntaxTree.GetCompilationUnitRoot(); + var globalStmt = root.Members.FirstOrDefault() as GlobalStatementSyntax; + var stmt = globalStmt?.Statement as ExpressionStatementSyntax; + + if (stmt?.Expression == null) return; + + ProcessExpressionForDataConnections(stmt.Expression, targetNode, context); + } + + private void ProcessExpressionForDataConnections(ExpressionSyntax expr, BlueprintNode targetNode, ConversionContext context) + { + switch (expr) + { + case LiteralExpressionSyntax literal: + CreateConstNodeForLiteral(literal, targetNode, context); + break; + + case IdentifierNameSyntax identifier: + CreateDataConnectionForVariable(identifier.Identifier.Text, targetNode, context); + break; + + case AssignmentExpressionSyntax assignment: + ProcessExpressionForDataConnections(assignment.Right, targetNode, context); + break; + + case InvocationExpressionSyntax invoke: + var funcName = GetMethodNameFromExpression(invoke); + + // ✅ 注意:Get 和 Set 的处理在 ProcessArgumentExpression 中已正确完成 + // 这里只需要处理其他函数调用(HelperFuncAdd 等)的情况 + if (funcName != "Get" && funcName != "Set") + { + // ✅ 始终创建新的 Call 节点(不复用) + // 因为 condition 表达式(BEQ/BLT/BLE)是不同的函数调用,不应该复用 + var isHelper = context.Script.HelperFunctions.Any(h => h.Name == funcName); + BlueprintNode callNode; + if (isHelper) + callNode = new CallHelperNode { HelperFunctionName = funcName, Name = $"Helper:{funcName}" }; + else + callNode = new CallNode { FunctionName = funcName, Name = $"Call:{funcName}" }; + + AddParameterPins(callNode, invoke.ArgumentList.Arguments); + context.Blueprint.AddNode(callNode); + + // ✅ 处理参数的连接 + CreateDataConnectionsForArguments(invoke.ArgumentList.Arguments, callNode, context); + + // ✅ 将函数调用的返回值连接到 targetNode 的条件引脚 + var returnPin = callNode.OutputPins.FirstOrDefault(p => p.Name == "Return"); + var targetPin = targetNode.InputPins.FirstOrDefault(p => p.Name != "Exec"); + if (returnPin != null && targetPin != null) + { + context.Blueprint.AddConnection(new BlueprintConnection + { + SourceNodeId = callNode.Id, + SourcePinId = returnPin.Id, + TargetNodeId = targetNode.Id, + TargetPinId = targetPin.Id, + PubVarName = null + }); + } + } + break; + + default: + var exprStr = expr.ToString(); + if (IsVariableReference(exprStr)) + { + CreateDataConnectionForVariable(exprStr, targetNode, context); + } + break; + } + } + + private void CreateConstNodeForLiteral(LiteralExpressionSyntax literal, BlueprintNode targetNode, ConversionContext context) + { + var targetPin = targetNode.InputPins.FirstOrDefault(p => p.Name != "Exec"); + if (targetPin == null) return; + + if (literal.Token.Value is string strVal) + { + // ✅ 不再创建 ConstNode,直接设置预设值 + targetPin.DefaultValue = strVal; + Log.Debug("Set DefaultValue for {TargetNode}.{TargetPin}: {Value}", targetNode.Name, targetPin.Name, strVal); + } + else if (literal.Token.Value is int intVal) + { + // ✅ 不再创建 ConstNode,直接设置预设值 + targetPin.DefaultValue = intVal.ToString(); + Log.Debug("Set DefaultValue for {TargetNode}.{TargetPin}: {Value}", targetNode.Name, targetPin.Name, intVal); + } + } + + /// + /// Sets DefaultValue for a literal and connects it to a specific parameter pin. + /// Used by ProcessArgumentExpression to set literal defaults for exact parameter pins. + /// + private void CreateConstNodeForLiteralWithPin(LiteralExpressionSyntax literal, BlueprintPin targetPin, BlueprintNode targetNode, ConversionContext context) + { + if (literal.Token.Value is string strVal) + { + // ✅ 不再创建 ConstNode,直接设置预设值 + targetPin.DefaultValue = strVal; + Log.Debug("Set DefaultValue for {TargetNode}.{TargetPin}: {Value}", targetNode.Name, targetPin.Name, strVal); + } + else if (literal.Token.Value is int intVal) + { + // ✅ 不再创建 ConstNode,直接设置预设值 + targetPin.DefaultValue = intVal.ToString(); + Log.Debug("Set DefaultValue for {TargetNode}.{TargetPin}: {Value}", targetNode.Name, targetPin.Name, intVal); + } + } + + private void CreateDataConnectionsForArguments(SeparatedSyntaxList? arguments, BlueprintNode callNode, ConversionContext context) + { + if (!arguments.HasValue || arguments.Value.Count == 0) return; + + // NOTE: AddParameterPins names parameters using node.InputPins.Count at the time of creation. + // Since CallNode/CallHelperNode already has Exec (at count=0), the first parameter gets name "param1". + // Therefore paramIndex starts at 1 to match the naming convention. + var paramIndex = 1; + foreach (var arg in arguments.Value) + { + var paramPin = callNode.InputPins.FirstOrDefault(p => p.Name == $"param{paramIndex}"); + if (paramPin == null) break; + paramIndex++; + ProcessArgumentExpression(arg.Expression, paramPin, callNode, context); + } + } + + private void ProcessArgumentExpression(ExpressionSyntax expr, BlueprintPin targetPin, BlueprintNode callNode, ConversionContext context) + { + switch (expr) + { + case InvocationExpressionSyntax invoke: + var funcName = GetMethodNameFromExpression(invoke); + Log.Debug("[ProcessArgumentExpression] InvocationExpressionSyntax: funcName={FuncName}, expr={Expr}", + funcName, expr.ToString()); + + if (funcName == "Get") + { + var varName = invoke.ArgumentList.Arguments.FirstOrDefault()?.Expression.ToString() ?? string.Empty; + + // ✅ Check if varName is a PubVarBlock variable + var isPubVar = context.Script.PubVarBlock?.Variables + .Any(v => v.Name == varName) == true; + + if (isPubVar) + { + // ✅ PubVar: 不创建 GetNode,直接从 VariableSources 获取 + // 查找时使用简单变量名(PubVarNames 中记录的名称) + var source = context.VariableSources.Values + .FirstOrDefault(s => s.PubVarName == varName); + if (source != null) + { + context.Blueprint.AddConnection(new BlueprintConnection + { + SourceNodeId = source.Node.Id, + SourcePinId = source.Pin.Id, + TargetNodeId = callNode.Id, + TargetPinId = targetPin.Id, + PubVarName = varName // ✅ PubVar 变量设置 PubVarName + }); + } + else + { + Log.Warning("[ProcessArgumentExpression] PubVar {VarName} not found in VariableSources", varName); + } + } + else + { + // ✅ ConstBlock variable: 创建独立的 GetNode(不连接到 ConstNode) + var getNode = new GetNode { VarName = varName, Name = $"Get:{varName}" }; + context.Blueprint.AddNode(getNode); + var getValuePin = getNode.OutputPins.First(p => p.Name == "Value"); + + // 连接到目标参数(GetNode 通过 VarName 在运行时获取 ConstBlock 值) + context.Blueprint.AddConnection(new BlueprintConnection + { + SourceNodeId = getNode.Id, + SourcePinId = getValuePin.Id, + TargetNodeId = callNode.Id, + TargetPinId = targetPin.Id, + PubVarName = null // ✅ ConstBlock 变量 + }); + } + } + else if (funcName == "Set") + { + var args = invoke.ArgumentList.Arguments; + if (args.Count >= 2) + { + var varName = args[0].Expression.ToString(); + var valueExpr = args[1].Expression; + var setNode = new SetNode { VarName = varName, Name = $"Set:{varName}" }; + context.Blueprint.AddNode(setNode); + var setValuePin = setNode.InputPins.FirstOrDefault(p => p.Name == "Value"); + if (setValuePin != null) + { + ProcessArgumentExpression(valueExpr, setValuePin, setNode, context); + } + } + } + else + { + var isHelper = context.Script.HelperFunctions.Any(h => h.Name == funcName); + BlueprintNode innerCallNode = isHelper + ? new CallHelperNode { HelperFunctionName = funcName, Name = $"Helper:{funcName}" } + : new CallNode { FunctionName = funcName, Name = $"Call:{funcName}" }; + + AddParameterPins(innerCallNode, invoke.ArgumentList.Arguments); + context.Blueprint.AddNode(innerCallNode); + context.PendingNodesForExecChain.Add(innerCallNode); + CreateDataConnectionsForArguments(invoke.ArgumentList.Arguments, innerCallNode, context); + + var returnPin = innerCallNode.OutputPins.FirstOrDefault(p => p.Name == "Return"); + if (returnPin != null) + { + context.Blueprint.AddConnection(new BlueprintConnection + { + SourceNodeId = innerCallNode.Id, + SourcePinId = returnPin.Id, + TargetNodeId = callNode.Id, + TargetPinId = targetPin.Id, + PubVarName = null + }); + context.VariableSources[$"__call_{funcName}_{Guid.NewGuid():N}__"] = new VariableSource + { + Node = innerCallNode, + Pin = returnPin, + PubVarName = null + }; + } + } + break; + + case LiteralExpressionSyntax literal: + CreateConstNodeForLiteralWithPin(literal, targetPin, callNode, context); + break; + + case IdentifierNameSyntax identifier: + CreateDataConnectionForVariable(identifier.Identifier.Text, targetPin, callNode, context); + break; + + default: + var exprStr = expr.ToString(); + if (IsVariableReference(exprStr)) + { + CreateDataConnectionForVariable(exprStr, targetPin, callNode, context); + } + break; + } + } + + private void CreateDataConnectionForVariable(string varName, BlueprintPin targetPin, BlueprintNode callNode, ConversionContext context) + { + if (context.Script.ConstBlock?.Variables.Any(v => v.Name == varName) == true) + { + // ✅ ConstBlock 变量:直接从 ConstNode 建立连接到目标节点 + var constNode = context.Blueprint.Nodes.OfType().FirstOrDefault(c => c.ConstName == varName); + if (constNode != null) + { + var constPin = constNode.OutputPins.First(p => p.Name == "Value"); + context.Blueprint.AddConnection(new BlueprintConnection + { + SourceNodeId = constNode.Id, + SourcePinId = constPin.Id, + TargetNodeId = callNode.Id, + TargetPinId = targetPin.Id, + PubVarName = null // ✅ ConstBlock 变量不应设置 PubVarName + }); + } + } + else + { + // ✅ PubVar 或其他变量:按 PubVarName 查找 VariableSources + var source = context.VariableSources.Values.FirstOrDefault(s => s.PubVarName == varName); + if (source != null) + { + context.Blueprint.AddConnection(new BlueprintConnection + { + SourceNodeId = source.Node.Id, + SourcePinId = source.Pin.Id, + TargetNodeId = callNode.Id, + TargetPinId = targetPin.Id, + PubVarName = source.PubVarName + }); + } + } + } + + private void CreateDataConnectionForVariable(string varName, BlueprintNode targetNode, ConversionContext context) + { + var targetPin = targetNode.InputPins.FirstOrDefault(p => p.Name != "Exec"); + if (targetPin == null) return; + + if (context.Script.ConstBlock?.Variables.Any(v => v.Name == varName) == true) + { + // ✅ ConstBlock 变量:直接从 ConstNode 建立连接到目标节点 + var constNode = context.Blueprint.Nodes.OfType().FirstOrDefault(c => c.ConstName == varName); + if (constNode != null) + { + var constPin = constNode.OutputPins.First(p => p.Name == "Value"); + context.Blueprint.AddConnection(new BlueprintConnection + { + SourceNodeId = constNode.Id, + SourcePinId = constPin.Id, + TargetNodeId = targetNode.Id, + TargetPinId = targetPin.Id, + PubVarName = null // ✅ ConstBlock 变量不应设置 PubVarName + }); + } + } + else + { + // ✅ PubVar 或其他变量:按 PubVarName 查找 VariableSources + var source = context.VariableSources.Values.FirstOrDefault(s => s.PubVarName == varName); + if (source != null) + { + context.Blueprint.AddConnection(new BlueprintConnection + { + SourceNodeId = source.Node.Id, + SourcePinId = source.Pin.Id, + TargetNodeId = targetNode.Id, + TargetPinId = targetPin.Id, + PubVarName = source.PubVarName + }); + } + } + } + + public class VariableSource + { + public BlueprintNode Node { get; set; } = null!; + public BlueprintPin Pin { get; set; } = null!; + /// 如果是 PubVar,则记录 PubVarName;否则为 null + public string? PubVarName { get; set; } + } + + internal class ConversionContext + { + public required Contract.Workflow.Blueprint Blueprint { get; set; } + public required BlockScript Script { get; set; } + public Dictionary NodeMap { get; set; } = new(); + public Dictionary NamedBlockMap { get; set; } = new(); + public Dictionary BlockFirstNodes { get; set; } = new(); + public EntryNode? EntryNode { get; set; } + public int NextPubVarIndex { get; set; } + public HashSet VisitedBlocks { get; set; } = new(); + public Dictionary VariableSources { get; set; } = new(); + public List DataConnections { get; set; } = new(); + public Dictionary LoopNodesByParentBlock { get; set; } = new(); + public BlueprintNode? LastProcessedNode { get; set; } + /// + /// 当前正在处理的父块名称(用于 Loop 节点复用) + /// + public string? CurrentParentBlock { get; set; } + /// + /// 鍦ㄥ弬鏁板鐞嗕腑鍒涘缓鐨勫祵濂楄皟鐢ㄨ妭鐐癸紝闇€瑕佹坊鍔犲埌鎵ц閾句腑 + /// + public List PendingNodesForExecChain { get; set; } = new(); + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintService.cs index dba55916..e8c01d59 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintService.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintService.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; using KitX.Core.Contract.Workflow; using Serilog; @@ -13,14 +14,25 @@ public class BlueprintService : IBlueprintService { private readonly IBlockScriptParser _parser; private readonly IBlockScriptExecutor _executor; + private readonly IFlowProcessingService _flowProcessingService; + private readonly IConnectionCreationService _connectionCreationService; + private readonly ILayoutService _layoutService; /// /// Creates a new BlueprintService instance /// - public BlueprintService(IBlockScriptParser parser, IBlockScriptExecutor executor) + public BlueprintService( + IBlockScriptParser parser, + IBlockScriptExecutor executor, + IFlowProcessingService flowProcessingService, + IConnectionCreationService connectionCreationService, + ILayoutService layoutService) { _parser = parser; _executor = executor; + _flowProcessingService = flowProcessingService; + _connectionCreationService = connectionCreationService; + _layoutService = layoutService; } /// @@ -50,7 +62,11 @@ public Contract.Workflow.Blueprint CreateBlueprint() { Log.Information("Importing Blueprint from BlockScript"); - var converter = new BlockScriptToBlueprintConverter(_parser); + var converter = new BlockScriptToBlueprintConverter( + _parser, + _flowProcessingService, + _connectionCreationService, + _layoutService); var blueprint = converter.Convert(sourceCode, helperFunctions); blueprint.ModifiedAt = DateTime.Now; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ConnectionCreationService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ConnectionCreationService.cs new file mode 100644 index 00000000..7daad8e9 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ConnectionCreationService.cs @@ -0,0 +1,399 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using KitX.Core.Contract.Workflow; +using Serilog; + +namespace KitX.Core.Workflow.Blueprint; + +public class ConnectionCreationService : IConnectionCreationService +{ + public void CreateExecConnection(BlueprintNode source, BlueprintNode target, Contract.Workflow.Blueprint blueprint) + { + var sourceExecOut = source.OutputPins.FirstOrDefault(p => p.Name == "Exec"); + var targetExecIn = target.InputPins.FirstOrDefault(p => p.Name == "Exec"); + if (sourceExecOut == null || targetExecIn == null) { Log.Warning("Cannot create Exec connection: missing Exec pin"); return; } + if (sourceExecOut.Direction != PinDirection.Output) { Log.Warning("Cannot create Exec connection: source.Exec is not an output pin"); return; } + if (source.Id == target.Id) { Log.Warning("Skipping self-loop Exec connection"); return; } + blueprint.AddConnection(new BlueprintConnection { SourceNodeId = source.Id, SourcePinId = sourceExecOut.Id, TargetNodeId = target.Id, TargetPinId = targetExecIn.Id }); + } + + public void CreateDataConnection(BlueprintNode source, string sourcePin, BlueprintNode target, string targetPin, string? pubVarName, Contract.Workflow.Blueprint blueprint) + { + var srcPin = source.OutputPins.FirstOrDefault(p => p.Name == sourcePin); + var tgtPin = target.InputPins.FirstOrDefault(p => p.Name == targetPin); + if (srcPin == null || tgtPin == null) { Log.Warning("Cannot create data connection: missing pin"); return; } + blueprint.AddConnection(new BlueprintConnection { SourceNodeId = source.Id, SourcePinId = srcPin.Id, TargetNodeId = target.Id, TargetPinId = tgtPin.Id, PubVarName = pubVarName }); + } + + public void LinkNodesWithExec(List nodes, Contract.Workflow.Blueprint blueprint) + { + for (int i = 0; i < nodes.Count - 1; i++) + { + var currentNode = nodes[i]; + var nextNode = nodes[i + 1]; + if (currentNode.Id == nextNode.Id) continue; + var currentExecOut = currentNode.OutputPins.FirstOrDefault(p => p.Name == "Exec"); + if (currentExecOut == null) { Log.Debug("Skipping Exec link"); continue; } + if (currentExecOut.Direction != PinDirection.Output) { Log.Warning("Cannot create Exec connection"); continue; } + var nextExecIn = nextNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); + if (nextExecIn == null) { Log.Warning("Cannot create Exec connection"); continue; } + blueprint.AddConnection(new BlueprintConnection { SourceNodeId = currentNode.Id, SourcePinId = currentExecOut.Id, TargetNodeId = nextNode.Id, TargetPinId = nextExecIn.Id }); + } + } + + + public void CreateDataConnectionsForExpression(string expression, BlueprintNode targetNode, ConversionContext context) + { + var wrappedCode = "_ = " + expression + ";"; + var syntaxTree = CSharpSyntaxTree.ParseText(wrappedCode, cancellationToken: CancellationToken.None); + var root = syntaxTree.GetCompilationUnitRoot(); + var globalStmt = root.Members.FirstOrDefault() as GlobalStatementSyntax; + var stmt = globalStmt?.Statement as ExpressionStatementSyntax; + + if (stmt?.Expression == null) return; + + ProcessExpressionForDataConnections(stmt.Expression, targetNode, context); + } + + private void ProcessExpressionForDataConnections(ExpressionSyntax expr, BlueprintNode targetNode, ConversionContext context) + { + switch (expr) + { + case LiteralExpressionSyntax literal: + CreateConstNodeForLiteral(literal, targetNode, context); + break; + + case IdentifierNameSyntax identifier: + CreateDataConnectionForVariable(identifier.Identifier.Text, targetNode, context); + break; + + case AssignmentExpressionSyntax assignment: + ProcessExpressionForDataConnections(assignment.Right, targetNode, context); + break; + + case InvocationExpressionSyntax invoke: + var funcName = GetMethodNameFromExpression(invoke); + + if (funcName != "Get" && funcName != "Set") + { + var isHelper = context.Script.HelperFunctions.Any(h => h.Name == funcName); + BlueprintNode callNode; + if (isHelper) + callNode = new CallHelperNode { HelperFunctionName = funcName, Name = "Helper:" + funcName }; + else + callNode = new CallNode { FunctionName = funcName, Name = "Call:" + funcName }; + + AddParameterPins(callNode, invoke.ArgumentList.Arguments); + context.Blueprint.AddNode(callNode); + context.PendingNodesForExecChain.Add(callNode); + + CreateDataConnectionsForArguments(invoke.ArgumentList.Arguments, callNode, context); + + var returnPin = callNode.OutputPins.FirstOrDefault(p => p.Name == "Return"); + var targetPin = targetNode.InputPins.FirstOrDefault(p => p.Name != "Exec"); + if (returnPin != null && targetPin != null) + { + context.Blueprint.AddConnection(new BlueprintConnection + { + SourceNodeId = callNode.Id, + SourcePinId = returnPin.Id, + TargetNodeId = targetNode.Id, + TargetPinId = targetPin.Id, + PubVarName = null + }); + } + } + break; + + default: + var exprStr = expr.ToString(); + if (IsVariableReference(exprStr)) + { + CreateDataConnectionForVariable(exprStr, targetNode, context); + } + break; + } + } + + + public void CreateDataConnectionsForArguments(SeparatedSyntaxList? arguments, BlueprintNode callNode, ConversionContext context) + { + if (!arguments.HasValue || arguments.Value.Count == 0) return; + + var paramIndex = 1; + foreach (var arg in arguments.Value) + { + var paramPin = callNode.InputPins.FirstOrDefault(p => p.Name == "param" + paramIndex); + if (paramPin == null) break; + paramIndex++; + ProcessArgumentExpression(arg.Expression, paramPin, callNode, context); + } + } + + public void AddParameterPins(BlueprintNode node, SeparatedSyntaxList? arguments) + { + if (!arguments.HasValue || arguments.Value.Count == 0) return; + + foreach (var arg in arguments.Value) + { + node.InputPins.Add(new BlueprintPin + { + Name = "param" + node.InputPins.Count, + Direction = PinDirection.Input, + Type = PinType.Any + }); + } + } + + public void CreateDataConnectionForVariable(string varName, BlueprintPin targetPin, BlueprintNode callNode, ConversionContext context) + { + if (context.Script.ConstBlock?.Variables.Any(v => v.Name == varName) == true) + { + var constNode = context.Blueprint.Nodes.OfType().FirstOrDefault(c => c.ConstName == varName); + if (constNode != null) + { + var constPin = constNode.OutputPins.First(p => p.Name == "Value"); + context.Blueprint.AddConnection(new BlueprintConnection + { + SourceNodeId = constNode.Id, + SourcePinId = constPin.Id, + TargetNodeId = callNode.Id, + TargetPinId = targetPin.Id, + PubVarName = null + }); + } + } + else + { + var source = context.VariableSources.Values.FirstOrDefault(s => s.PubVarName == varName); + if (source != null) + { + context.Blueprint.AddConnection(new BlueprintConnection + { + SourceNodeId = source.Node.Id, + SourcePinId = source.Pin.Id, + TargetNodeId = callNode.Id, + TargetPinId = targetPin.Id, + PubVarName = source.PubVarName + }); + } + } + } + + public void CreateDataConnectionForVariable(string varName, BlueprintNode targetNode, ConversionContext context) + { + var targetPin = targetNode.InputPins.FirstOrDefault(p => p.Name != "Exec"); + if (targetPin == null) return; + + if (context.Script.ConstBlock?.Variables.Any(v => v.Name == varName) == true) + { + var constNode = context.Blueprint.Nodes.OfType().FirstOrDefault(c => c.ConstName == varName); + if (constNode != null) + { + var constPin = constNode.OutputPins.First(p => p.Name == "Value"); + context.Blueprint.AddConnection(new BlueprintConnection + { + SourceNodeId = constNode.Id, + SourcePinId = constPin.Id, + TargetNodeId = targetNode.Id, + TargetPinId = targetPin.Id, + PubVarName = null + }); + } + } + else + { + var source = context.VariableSources.Values.FirstOrDefault(s => s.PubVarName == varName); + if (source != null) + { + context.Blueprint.AddConnection(new BlueprintConnection + { + SourceNodeId = source.Node.Id, + SourcePinId = source.Pin.Id, + TargetNodeId = targetNode.Id, + TargetPinId = targetPin.Id, + PubVarName = source.PubVarName + }); + } + } + } + + public void ProcessArgumentExpression(ExpressionSyntax expr, BlueprintPin targetPin, BlueprintNode callNode, ConversionContext context) + { + switch (expr) + { + case InvocationExpressionSyntax invoke: + var funcName = GetMethodNameFromExpression(invoke); + Log.Debug("[ProcessArgumentExpression] InvocationExpressionSyntax: funcName={FuncName}, expr={Expr}", + funcName, expr.ToString()); + + if (funcName == "Get") + { + var varName = invoke.ArgumentList.Arguments.FirstOrDefault()?.Expression.ToString() ?? string.Empty; + + var isPubVar = context.Script.PubVarBlock?.Variables + .Any(v => v.Name == varName) == true; + + if (isPubVar) + { + var source = context.VariableSources.Values + .FirstOrDefault(s => s.PubVarName == varName); + if (source != null) + { + context.Blueprint.AddConnection(new BlueprintConnection + { + SourceNodeId = source.Node.Id, + SourcePinId = source.Pin.Id, + TargetNodeId = callNode.Id, + TargetPinId = targetPin.Id, + PubVarName = varName + }); + } + else + { + Log.Warning("[ProcessArgumentExpression] PubVar {VarName} not found in VariableSources", varName); + } + } + else + { + var getNode = new GetNode { VarName = varName, Name = "Get:" + varName }; + context.Blueprint.AddNode(getNode); + context.PendingNodesForExecChain.Add(getNode); + var getValuePin = getNode.OutputPins.First(p => p.Name == "Value"); + + context.Blueprint.AddConnection(new BlueprintConnection + { + SourceNodeId = getNode.Id, + SourcePinId = getValuePin.Id, + TargetNodeId = callNode.Id, + TargetPinId = targetPin.Id, + PubVarName = null + }); + } + } + else if (funcName == "Set") + { + var args = invoke.ArgumentList.Arguments; + if (args.Count >= 2) + { + var varName = args[0].Expression.ToString(); + var valueExpr = args[1].Expression; + var setNode = new SetNode { VarName = varName, Name = "Set:" + varName }; + context.Blueprint.AddNode(setNode); + var setValuePin = setNode.InputPins.FirstOrDefault(p => p.Name == "Value"); + if (setValuePin != null) + { + ProcessArgumentExpression(valueExpr, setValuePin, setNode, context); + } + } + } + else + { + var isHelper = context.Script.HelperFunctions.Any(h => h.Name == funcName); + BlueprintNode innerCallNode = isHelper + ? new CallHelperNode { HelperFunctionName = funcName, Name = "Helper:" + funcName } + : new CallNode { FunctionName = funcName, Name = "Call:" + funcName }; + + AddParameterPins(innerCallNode, invoke.ArgumentList.Arguments); + context.Blueprint.AddNode(innerCallNode); + context.PendingNodesForExecChain.Add(innerCallNode); + CreateDataConnectionsForArguments(invoke.ArgumentList.Arguments, innerCallNode, context); + + var returnPin = innerCallNode.OutputPins.FirstOrDefault(p => p.Name == "Return"); + if (returnPin != null) + { + context.Blueprint.AddConnection(new BlueprintConnection + { + SourceNodeId = innerCallNode.Id, + SourcePinId = returnPin.Id, + TargetNodeId = callNode.Id, + TargetPinId = targetPin.Id, + PubVarName = null + }); + context.VariableSources["__call_" + funcName + "_" + Guid.NewGuid().ToString("N") + "__"] = new VariableSource + { + Node = innerCallNode, + Pin = returnPin, + PubVarName = null + }; + } + } + break; + + case LiteralExpressionSyntax literal: + CreateConstNodeForLiteralWithPin(literal, targetPin, callNode, context); + break; + + case IdentifierNameSyntax identifier: + CreateDataConnectionForVariable(identifier.Identifier.Text, targetPin, callNode, context); + break; + + default: + var exprStr = expr.ToString(); + if (IsVariableReference(exprStr)) + { + CreateDataConnectionForVariable(exprStr, targetPin, callNode, context); + } + break; + } + } + + public void CreateConstNodeForLiteral(LiteralExpressionSyntax literal, BlueprintNode targetNode, ConversionContext context) + { + var targetPin = targetNode.InputPins.FirstOrDefault(p => p.Name != "Exec"); + if (targetPin == null) return; + + if (literal.Token.Value is string strVal) + { + targetPin.DefaultValue = strVal; + Log.Debug("Set DefaultValue for {TargetNode}.{TargetPin}: {Value}", targetNode.Name, targetPin.Name, strVal); + } + else if (literal.Token.Value is int intVal) + { + targetPin.DefaultValue = intVal.ToString(); + Log.Debug("Set DefaultValue for {TargetNode}.{TargetPin}: {Value}", targetNode.Name, targetPin.Name, intVal); + } + } + + public void CreateConstNodeForLiteralWithPin(LiteralExpressionSyntax literal, BlueprintPin targetPin, BlueprintNode targetNode, ConversionContext context) + { + if (literal.Token.Value is string strVal) + { + targetPin.DefaultValue = strVal; + Log.Debug("Set DefaultValue for {TargetNode}.{TargetPin}: {Value}", targetNode.Name, targetPin.Name, strVal); + } + else if (literal.Token.Value is int intVal) + { + targetPin.DefaultValue = intVal.ToString(); + Log.Debug("Set DefaultValue for {TargetNode}.{TargetPin}: {Value}", targetNode.Name, targetPin.Name, intVal); + } + } + + public bool IsVariableReference(string token) + { + if (string.IsNullOrWhiteSpace(token)) return false; + if (bool.TryParse(token, out _)) return false; + if (int.TryParse(token, out _) || double.TryParse(token, out _)) return false; + if (token.StartsWith("'") && token.EndsWith("'")) return false; + if (token.StartsWith("'") && token.EndsWith("'")) return false; + if (token.Contains("(")) return false; + var regex = new System.Text.RegularExpressions.Regex(@"^[a-zA-Z_]\w*$"); + return regex.IsMatch(token); + } + + public string GetMethodNameFromExpression(InvocationExpressionSyntax invoke) + { + if (invoke.Expression is IdentifierNameSyntax id) + return id.Identifier.Text; + if (invoke.Expression is GenericNameSyntax generic) + return generic.Identifier.Text; + if (invoke.Expression is MemberAccessExpressionSyntax member) + return member.Name.Identifier.Text; + return string.Empty; + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ConversionContext.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ConversionContext.cs new file mode 100644 index 00000000..c48f9426 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ConversionContext.cs @@ -0,0 +1 @@ +using System; using System.Collections.Generic; using KitX.Core.Contract.Workflow; namespace KitX.Core.Workflow.Blueprint { public class VariableSource { public BlueprintNode Node { get; set; } = null!; public BlueprintPin Pin { get; set; } = null!; public string? PubVarName { get; set; } } public class ConversionContext { public required Contract.Workflow.Blueprint Blueprint { get; set; } public required BlockScript Script { get; set; } public Dictionary NodeMap { get; set; } = new(); public Dictionary NamedBlockMap { get; set; } = new(); public Dictionary BlockFirstNodes { get; set; } = new(); public EntryNode? EntryNode { get; set; } public int NextPubVarIndex { get; set; } public HashSet VisitedBlocks { get; set; } = new(); public Dictionary VariableSources { get; set; } = new(); public List DataConnections { get; set; } = new(); public Dictionary LoopNodesByParentBlock { get; set; } = new(); public BlueprintNode? LastProcessedNode { get; set; } public string? CurrentParentBlock { get; set; } public List PendingNodesForExecChain { get; set; } = new(); } } \ No newline at end of file diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/FlowProcessingService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/FlowProcessingService.cs new file mode 100644 index 00000000..dc232bee --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/FlowProcessingService.cs @@ -0,0 +1,953 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using KitX.Core.Contract.Workflow; +using Serilog; + +namespace KitX.Core.Workflow.Blueprint; + +public class FlowProcessingService : IFlowProcessingService +{ + private readonly IConnectionCreationService _connectionCreationService; + + public FlowProcessingService(IConnectionCreationService connectionCreationService) + { + _connectionCreationService = connectionCreationService; + } + + public void ProcessConstBlock(BlockDefinition block, ConversionContext context) + { + foreach (var variable in block.Variables) + { + var constValue = variable.DefaultValue?.ToString(); + if (string.IsNullOrEmpty(constValue)) + { + constValue = variable.Type.ToLower() switch + { + "int" or "integer" or "long" or "short" or "byte" => "0", + "float" or "double" or "decimal" => "0", + "bool" or "boolean" => "false", + "string" => "", + "char" => "\0", + _ => "" + }; + } + + var constNode = new ConstNode + { + ConstName = variable.Name, + ConstType = variable.Type, + ConstValue = constValue + }; + + // Set Value pin PinType based on ConstType for correct data edge coloring + var valuePin = constNode.OutputPins.FirstOrDefault(p => p.Name == "Value"); + if (valuePin != null) + { + valuePin.Type = variable.Type.ToLower() switch + { + "int" or "integer" or "long" or "short" or "byte" => PinType.Integer, + "float" or "double" or "decimal" => PinType.Double, + "bool" or "boolean" => PinType.Boolean, + "string" or "char" => PinType.String, + _ => PinType.Any + }; + } + + context.Blueprint.AddNode(constNode); + context.Blueprint.ConstValues.Add(new VariableConstant + { + Name = variable.Name, + Type = variable.Type, + DefaultValue = variable.DefaultValue + }); + Log.Debug("Created ConstNode: Name={ConstName}, Id={NodeId}", constNode.Name, constNode.Id); + } + } + + public void ProcessPubVarBlock(BlockDefinition block, ConversionContext context) + { + foreach (var variable in block.Variables) + { + context.Blueprint.PubVarNames.Add(variable.Name); + } + } + + + public void ProcessMainBlock(BlockDefinition block, ConversionContext context, INodeCreationService nodeCreationService) + { + var entryNode = new EntryNode + { + X = 50, + Y = 50 + }; + context.Blueprint.AddNode(entryNode); + context.EntryNode = entryNode; + Log.Debug("Created EntryNode: Name={NodeName}, Id={NodeId}", entryNode.Name, entryNode.Id); + + var mainFlowNodes = new List(); + var currentY = 150.0; + + for (int i = 0; i < block.Statements.Count; i++) + { + var statement = block.Statements[i]; + var node = ProcessStatement(statement, context, nodeCreationService, 50, currentY); + if (node != null) + { + context.Blueprint.AddNode(node); + mainFlowNodes.Add(node); + currentY += 120; + } + } + + _connectionCreationService.LinkNodesWithExec(mainFlowNodes, context.Blueprint); + + var flowNode = mainFlowNodes.LastOrDefault(n => n is BranchNode || n is LoopNode); + FlowControlStatement? flowCtrl = null; + + if (flowNode == null) + { + if (context.Script.LoopBlocks.TryGetValue("MainBlock", out var loopBlock)) + { + flowCtrl = loopBlock.Statements.FirstOrDefault(s => s is FlowControlStatement) as FlowControlStatement; + if (flowCtrl != null) + { + context.CurrentParentBlock = "MainBlock"; + var loopNodeInLoopBlock = ProcessFlowControlStatement(flowCtrl, context, nodeCreationService, 350, 150); + if (loopNodeInLoopBlock is LoopNode loopFromBlock) + { + flowNode = loopFromBlock; + context.LoopNodesByParentBlock["MainBlock"] = loopFromBlock; + } + } + } + } + else + { + flowCtrl = block.Statements.LastOrDefault(s => s is FlowControlStatement) as FlowControlStatement; + } + + if (flowNode != null && flowCtrl != null) + { + if (flowNode is BranchNode branch) + { + ProcessBranchNodeConnectionsInMain(branch, flowCtrl, context); + } + else if (flowNode is LoopNode loop) + { + ProcessLoopNodeConnectionsInMain(loop, flowCtrl, context); + } + } + + if (mainFlowNodes.Count > 0) + { + var firstNode = mainFlowNodes[0]; + var entryExecPin = entryNode.OutputPins.First(p => p.Name == "Exec"); + var firstExecPin = firstNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); + if (entryExecPin != null && firstExecPin != null) + { + context.Blueprint.AddConnection(new BlueprintConnection + { + SourceNodeId = entryNode.Id, + SourcePinId = entryExecPin.Id, + TargetNodeId = firstNode.Id, + TargetPinId = firstExecPin.Id + }); + } + } + } + + + public BlueprintNode? ProcessStatement(BlockStatement statement, ConversionContext context, INodeCreationService nodeCreationService, double x, double y) + { + switch (statement) + { + case FlowControlStatement flowCtrl: + if ((flowCtrl.ControlType == FlowControlType.Branch || flowCtrl.ControlType == FlowControlType.Loop) && + !string.IsNullOrEmpty(flowCtrl.TrueBlockName) && + !string.IsNullOrEmpty(flowCtrl.FalseBlockName)) + { + if (flowCtrl.ControlType == FlowControlType.Loop && + context.CurrentParentBlock != null && + context.LoopNodesByParentBlock.TryGetValue(context.CurrentParentBlock, out var existingLoop)) + { + context.LastProcessedNode = existingLoop; + return existingLoop; + } + + var flowNode = ProcessFlowControlStatement(flowCtrl, context, nodeCreationService, x, y); + if (flowNode != null) + { + context.LastProcessedNode = flowNode; + } + return flowNode; + } + if (flowCtrl.ControlType == FlowControlType.LoopBodyEnd) + { + HandleLoopBodyEnd(flowCtrl, context); + return null; + } + var otherFlowNode = ProcessFlowControlStatement(flowCtrl, context, nodeCreationService, x, y); + if (otherFlowNode != null) + { + context.LastProcessedNode = otherFlowNode; + } + return otherFlowNode; + + case ExpressionStatement expr: + if (expr.Expression.StartsWith("NextBlock = ")) + return null; + var actionNode = CreateActionNodeFromExpression(expr.Expression, context); + if (actionNode != null) + { + context.LastProcessedNode = actionNode; + } + return actionNode; + + default: + return null; + } + } + + public BlueprintNode? ProcessFlowControlStatement(FlowControlStatement flowCtrl, ConversionContext context, INodeCreationService nodeCreationService, double x, double y) + { + var node = CreateControlFlowNode(flowCtrl); + if (node == null) return null; + + node.X = x; + node.Y = y; + + if (!string.IsNullOrEmpty(flowCtrl.ConditionExpression)) + { + _connectionCreationService.CreateDataConnectionsForExpression(flowCtrl.ConditionExpression, node, context); + } + + return node; + } + + + public BlueprintNode? ProcessBlockRecursive(BlockDefinition block, ConversionContext context, INodeCreationService nodeCreationService, double x, double y) + { + if (context.VisitedBlocks.Contains(block.Name)) + { + return context.BlockFirstNodes.GetValueOrDefault(block.Name); + } + context.VisitedBlocks.Add(block.Name); + + var nodes = new List(); + var currentY = y; + + foreach (var statement in block.Statements) + { + var node = ProcessStatement(statement, context, nodeCreationService, x, currentY); + if (node != null) + { + if (!context.Blueprint.Nodes.Any(n => n.Id == node.Id)) + { + context.Blueprint.AddNode(node); + } + nodes.Add(node); + currentY += 120; + + if (context.PendingNodesForExecChain.Count > 0) + { + var parentNodeIndex = nodes.Count - 1; + foreach (var pendingNode in context.PendingNodesForExecChain) + { + if (!nodes.Any(n => n.Id == pendingNode.Id)) + { + nodes.Insert(parentNodeIndex, pendingNode); + parentNodeIndex++; + } + } + context.PendingNodesForExecChain.Clear(); + } + } + } + + if (nodes.Count == 0 && block.Statements.Count > 0) + { + var firstStatement = block.Statements[0]; + if (firstStatement is FlowControlStatement flowCtrl && + (flowCtrl.ControlType == FlowControlType.Branch || flowCtrl.ControlType == FlowControlType.Loop)) + { + var routingNode = ProcessFlowControlStatement(flowCtrl, context, nodeCreationService, x, y); + if (routingNode != null) + { + context.Blueprint.AddNode(routingNode); + nodes.Add(routingNode); + } + } + } + + _connectionCreationService.LinkNodesWithExec(nodes, context.Blueprint); + + context.VisitedBlocks.Add(block.Name); + + var flowNode = nodes.LastOrDefault(n => n is BranchNode || n is LoopNode); + if (flowNode != null) + { + if (flowNode is BranchNode branch) + { + ProcessBranchNodeConnections(branch, block, context, x, y); + } + else if (flowNode is LoopNode loop) + { + ProcessLoopNodeConnections(loop, block, context, x, y); + } + } + + if (!string.IsNullOrEmpty(block.NextBlockName) && + block.Type != BlockType.LoopBlock) + { + var nextBlock = context.Script.GetBlockByName(block.NextBlockName); + if (nextBlock != null && !context.VisitedBlocks.Contains(nextBlock.Name)) + { + if (nodes.Count > 0) + { + var lastNode = nodes.Last(); + var lastExecOut = lastNode.OutputPins.FirstOrDefault(p => p.Name == "Exec"); + + // Skip cross-block connection if lastNode's Exec output already has + // an external connection (e.g. from HandleLoopBodyEnd or branch/loop routing). + // This prevents duplicate connections like BLE → Print(猜大了) + // when the next block is also reachable via Branch True/False. + var lastExecAlreadyConnected = lastExecOut != null && + context.Blueprint.Connections.Any(c => + c.SourceNodeId == lastNode.Id && + c.SourcePinId == lastExecOut.Id && + c.TargetNodeId != lastNode.Id); + + if (!lastExecAlreadyConnected) + { + var nextFirstNode = ProcessBlockRecursive(nextBlock, context, nodeCreationService, x + 300, y); + if (nextFirstNode != null) + { + var nextExecIn = nextFirstNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); + + if (lastExecOut == null || lastExecOut.Direction != PinDirection.Output) + { + Log.Warning("Cannot create cross-block Exec connection: {Node}.Exec is not a valid output pin", + lastNode.Name); + } + else if (lastNode.Id == nextFirstNode.Id) + { + Log.Warning("Skipping self-loop cross-block Exec connection"); + } + else if (lastExecOut != null && nextExecIn != null) + { + context.Blueprint.AddConnection(new BlueprintConnection + { + SourceNodeId = lastNode.Id, + SourcePinId = lastExecOut.Id, + TargetNodeId = nextFirstNode.Id, + TargetPinId = nextExecIn.Id + }); + } + } + } + } + } + } + + var firstNode = nodes.FirstOrDefault(); + if (firstNode != null) + { + context.BlockFirstNodes[block.Name] = firstNode; + } + + return firstNode; + } + + + public void ProcessBranchNodeConnections(BranchNode branch, BlockDefinition block, ConversionContext context, double x, double y) + { + var flowCtrl = block.Statements.FirstOrDefault(s => s is FlowControlStatement) as FlowControlStatement; + if (flowCtrl == null) return; + + double targetY = y + 150; + + if (!string.IsNullOrEmpty(flowCtrl.TrueBlockName)) + { + var trueBlock = context.Script.GetBlockByName(flowCtrl.TrueBlockName); + if (trueBlock != null && !context.VisitedBlocks.Contains(trueBlock.Name)) + { + var firstTrueNode = ProcessBlockRecursive(trueBlock, context, null, x + 300, targetY); + if (firstTrueNode != null && firstTrueNode.Id != branch.Id) + { + var truePin = branch.OutputPins.FirstOrDefault(p => p.Name == "True"); + var firstExecIn = firstTrueNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); + if (truePin != null && firstExecIn != null) + { + context.Blueprint.AddConnection(new BlueprintConnection + { + SourceNodeId = branch.Id, + SourcePinId = truePin.Id, + TargetNodeId = firstTrueNode.Id, + TargetPinId = firstExecIn.Id + }); + } + } + } + } + + if (!string.IsNullOrEmpty(flowCtrl.FalseBlockName)) + { + var falseBlock = context.Script.GetBlockByName(flowCtrl.FalseBlockName); + if (falseBlock != null) + { + BlueprintNode? firstFalseNode; + if (context.VisitedBlocks.Contains(falseBlock.Name)) + { + firstFalseNode = context.BlockFirstNodes.GetValueOrDefault(falseBlock.Name); + } + else + { + firstFalseNode = ProcessBlockRecursive(falseBlock, context, null, x + 300, targetY + 200); + } + + if (firstFalseNode != null && firstFalseNode.Id != branch.Id && context.Blueprint.Nodes.Any(n => n.Id == firstFalseNode.Id)) + { + var actualTarget = GetFirstConnectableNode(firstFalseNode, context); + var falsePin = branch.OutputPins.FirstOrDefault(p => p.Name == "False"); + var actualExecIn = actualTarget?.InputPins.FirstOrDefault(p => p.Name == "Exec"); + + if (falsePin != null && actualExecIn != null && actualTarget != null && actualTarget.Id != branch.Id) + { + context.Blueprint.AddConnection(new BlueprintConnection + { + SourceNodeId = branch.Id, + SourcePinId = falsePin.Id, + TargetNodeId = actualTarget.Id, + TargetPinId = actualExecIn.Id + }); + } + } + } + } + } + + public void ProcessBranchNodeConnectionsInMain(BranchNode branch, FlowControlStatement flowCtrl, ConversionContext context) + { + double x = 350; + double targetY = 300; + + if (!string.IsNullOrEmpty(flowCtrl.TrueBlockName)) + { + var trueBlock = context.Script.GetBlockByName(flowCtrl.TrueBlockName); + if (trueBlock != null) + { + var firstTrueNode = ProcessBlockRecursive(trueBlock, context, null, x, targetY); + if (firstTrueNode != null) + { + var truePin = branch.OutputPins.FirstOrDefault(p => p.Name == "True"); + var firstExecIn = firstTrueNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); + if (truePin != null && firstExecIn != null) + { + context.Blueprint.AddConnection(new BlueprintConnection + { + SourceNodeId = branch.Id, + SourcePinId = truePin.Id, + TargetNodeId = firstTrueNode.Id, + TargetPinId = firstExecIn.Id + }); + } + } + } + } + + if (!string.IsNullOrEmpty(flowCtrl.FalseBlockName)) + { + var falseBlock = context.Script.GetBlockByName(flowCtrl.FalseBlockName); + if (falseBlock != null) + { + if (!context.VisitedBlocks.Contains(falseBlock.Name)) + { + var firstFalseNode = ProcessBlockRecursive(falseBlock, context, null, x, targetY + 200); + if (firstFalseNode != null && firstFalseNode.Id != branch.Id) + { + var falsePin = branch.OutputPins.FirstOrDefault(p => p.Name == "False"); + var firstExecIn = firstFalseNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); + if (falsePin != null && firstExecIn != null) + { + context.Blueprint.AddConnection(new BlueprintConnection + { + SourceNodeId = branch.Id, + SourcePinId = falsePin.Id, + TargetNodeId = firstFalseNode.Id, + TargetPinId = firstExecIn.Id + }); + } + } + } + } + } + } + + + public void ProcessLoopNodeConnections(LoopNode loop, BlockDefinition block, ConversionContext context, double x, double y) + { + var flowCtrl = block.Statements.FirstOrDefault(s => s is FlowControlStatement) as FlowControlStatement; + if (flowCtrl == null) return; + + double targetY = y + 150; + + if (!string.IsNullOrEmpty(flowCtrl.TrueBlockName)) + { + var loopBodyBlock = context.Script.GetBlockByName(flowCtrl.TrueBlockName); + if (loopBodyBlock != null) + { + context.LoopNodesByParentBlock[flowCtrl.TrueBlockName] = loop; + + var firstLoopNode = ProcessBlockRecursive(loopBodyBlock, context, null, x + 300, targetY); + if (firstLoopNode != null) + { + var loopBodyPin = loop.OutputPins.FirstOrDefault(p => p.Name == "LoopBody"); + var firstExecIn = firstLoopNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); + if (loopBodyPin != null && firstExecIn != null) + { + context.Blueprint.AddConnection(new BlueprintConnection + { + SourceNodeId = loop.Id, + SourcePinId = loopBodyPin.Id, + TargetNodeId = firstLoopNode.Id, + TargetPinId = firstExecIn.Id + }); + } + } + } + } + + if (!string.IsNullOrEmpty(flowCtrl.FalseBlockName)) + { + var afterLoopBlock = context.Script.GetBlockByName(flowCtrl.FalseBlockName); + if (afterLoopBlock != null) + { + var firstAfterLoopNode = ProcessBlockRecursive(afterLoopBlock, context, null, x + 300, targetY + 200); + if (firstAfterLoopNode != null) + { + var loopEndPin = loop.OutputPins.FirstOrDefault(p => p.Name == "LoopEnd"); + var firstExecIn = firstAfterLoopNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); + if (loopEndPin != null && firstExecIn != null) + { + context.Blueprint.AddConnection(new BlueprintConnection + { + SourceNodeId = loop.Id, + SourcePinId = loopEndPin.Id, + TargetNodeId = firstAfterLoopNode.Id, + TargetPinId = firstExecIn.Id + }); + } + } + } + } + + if (!string.IsNullOrEmpty(flowCtrl.LoopBodyEndReturnTo)) + { + context.LoopNodesByParentBlock[flowCtrl.LoopBodyEndReturnTo] = loop; + } + } + + public void ProcessLoopNodeConnectionsInMain(LoopNode loop, FlowControlStatement flowCtrl, ConversionContext context) + { + double x = 350; + double targetY = 300; + + if (!string.IsNullOrEmpty(flowCtrl.TrueBlockName)) + { + var loopBodyBlock = context.Script.GetBlockByName(flowCtrl.TrueBlockName); + if (loopBodyBlock != null) + { + context.LoopNodesByParentBlock[flowCtrl.TrueBlockName] = loop; + if (!string.IsNullOrEmpty(flowCtrl.LoopBodyEndReturnTo)) + { + context.LoopNodesByParentBlock[flowCtrl.LoopBodyEndReturnTo] = loop; + } + + var firstLoopNode = ProcessBlockRecursive(loopBodyBlock, context, null, x, targetY); + if (firstLoopNode != null) + { + var loopBodyPin = loop.OutputPins.FirstOrDefault(p => p.Name == "LoopBody"); + var firstExecIn = firstLoopNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); + if (loopBodyPin != null && firstExecIn != null) + { + context.Blueprint.AddConnection(new BlueprintConnection + { + SourceNodeId = loop.Id, + SourcePinId = loopBodyPin.Id, + TargetNodeId = firstLoopNode.Id, + TargetPinId = firstExecIn.Id + }); + } + } + } + } + + if (!string.IsNullOrEmpty(flowCtrl.FalseBlockName)) + { + var afterLoopBlock = context.Script.GetBlockByName(flowCtrl.FalseBlockName); + if (afterLoopBlock != null) + { + var firstAfterLoopNode = ProcessBlockRecursive(afterLoopBlock, context, null, x, targetY + 200); + if (firstAfterLoopNode != null) + { + var loopEndPin = loop.OutputPins.FirstOrDefault(p => p.Name == "LoopEnd"); + var firstExecIn = firstAfterLoopNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); + if (loopEndPin != null && firstExecIn != null) + { + context.Blueprint.AddConnection(new BlueprintConnection + { + SourceNodeId = loop.Id, + SourcePinId = loopEndPin.Id, + TargetNodeId = firstAfterLoopNode.Id, + TargetPinId = firstExecIn.Id + }); + } + } + } + } + + if (!string.IsNullOrEmpty(flowCtrl.LoopBodyEndReturnTo)) + { + context.LoopNodesByParentBlock[flowCtrl.LoopBodyEndReturnTo] = loop; + } + } + + + public void ProcessLoopBlock(BlockDefinition block, ConversionContext context, INodeCreationService nodeCreationService) + { + if (context.VisitedBlocks.Contains(block.Name)) return; + context.VisitedBlocks.Add(block.Name); + + var parentBlockName = block.ParentBlockName ?? block.Name; + context.CurrentParentBlock = parentBlockName; + + var nodes = new List(); + var currentY = 50.0; + + foreach (var statement in block.Statements) + { + var node = ProcessStatement(statement, context, nodeCreationService, 50, currentY); + if (node != null) + { + context.Blueprint.AddNode(node); + nodes.Add(node); + currentY += 120; + } + } + + _connectionCreationService.LinkNodesWithExec(nodes, context.Blueprint); + + foreach (var statement in block.Statements) + { + if (statement is FlowControlStatement flowCtrl) + { + if (!string.IsNullOrEmpty(flowCtrl.TrueBlockName)) + { + var trueBlock = context.Script.GetBlockByName(flowCtrl.TrueBlockName); + if (trueBlock != null && !context.VisitedBlocks.Contains(trueBlock.Name)) + ProcessBlockRecursive(trueBlock, context, nodeCreationService, 350, 50); + } + if (!string.IsNullOrEmpty(flowCtrl.FalseBlockName)) + { + var falseBlock = context.Script.GetBlockByName(flowCtrl.FalseBlockName); + if (falseBlock != null && !context.VisitedBlocks.Contains(falseBlock.Name)) + ProcessBlockRecursive(falseBlock, context, nodeCreationService, 350, 200); + } + } + } + } + + public void HandleLoopBodyEnd(FlowControlStatement flowCtrl, ConversionContext context) + { + if (!string.IsNullOrEmpty(flowCtrl.LoopBodyEndReturnTo)) + { + if (context.LoopNodesByParentBlock.TryGetValue(flowCtrl.LoopBodyEndReturnTo, out var loopNode)) + { + var lastNode = context.LastProcessedNode; + + if (lastNode != null) + { + var loopConditionIn = loopNode.InputPins.FirstOrDefault(p => p.Name == "Condition"); + BlueprintNode? bleNode = null; + + if (loopConditionIn != null) + { + var conditionConn = context.Blueprint.Connections + .FirstOrDefault(c => c.TargetNodeId == loopNode.Id && + c.TargetPinId == loopConditionIn.Id); + if (conditionConn != null) + { + bleNode = context.Blueprint.Nodes.FirstOrDefault(n => n.Id == conditionConn.SourceNodeId); + } + } + + if (bleNode == null && !string.IsNullOrEmpty(flowCtrl.ConditionExpression)) + { + var conditionVarName = flowCtrl.ConditionExpression.Trim(); + var source = context.VariableSources.Values + .FirstOrDefault(s => s.PubVarName == conditionVarName); + if (source != null) + { + bleNode = source.Node; + } + } + + if (bleNode != null) + { + var lastExecOut = lastNode.OutputPins.FirstOrDefault(p => p.Name == "Exec"); + var bleExecIn = bleNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); + var bleExecOut = bleNode.OutputPins.FirstOrDefault(p => p.Name == "Exec"); + var loopExecIn = loopNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); + + if (lastExecOut != null && bleExecIn != null && lastNode.Id != bleNode.Id) + { + var hasConnection = context.Blueprint.Connections + .Any(c => c.SourceNodeId == lastNode.Id && + c.SourcePinId == lastExecOut.Id && + c.TargetNodeId == bleNode.Id && + c.TargetPinId == bleExecIn.Id); + if (!hasConnection) + { + context.Blueprint.AddConnection(new BlueprintConnection + { + SourceNodeId = lastNode.Id, + SourcePinId = lastExecOut.Id, + TargetNodeId = bleNode.Id, + TargetPinId = bleExecIn.Id + }); + } + } + + if (bleExecOut != null && loopExecIn != null) + { + var hasConnection = context.Blueprint.Connections + .Any(c => c.SourceNodeId == bleNode.Id && + c.SourcePinId == bleExecOut.Id && + c.TargetNodeId == loopNode.Id && + c.TargetPinId == loopExecIn.Id); + if (!hasConnection) + { + context.Blueprint.AddConnection(new BlueprintConnection + { + SourceNodeId = bleNode.Id, + SourcePinId = bleExecOut.Id, + TargetNodeId = loopNode.Id, + TargetPinId = loopExecIn.Id + }); + } + } + } + } + + context.LoopNodesByParentBlock[flowCtrl.LoopBodyEndReturnTo] = loopNode; + } + } + } + + public BlueprintNode? GetFirstConnectableNode(BlueprintNode node, ConversionContext context) + { + return node; + } + + + private BlueprintNode? CreateControlFlowNode(FlowControlStatement statement) + { + switch (statement.ControlType) + { + case FlowControlType.Branch: + return new BranchNode(); + case FlowControlType.Loop: + return new LoopNode(); + case FlowControlType.Break: + return new BreakNode(); + default: + return null; + } + } + + private BlueprintNode? CreateActionNodeFromExpression(string expression, ConversionContext context) + { + var parsed = TryParseInvocationExpression(expression); + if (parsed == null) + return null; + + var (funcName, args, assignment) = parsed.Value; + + if (funcName == "Get") + { + var varName = args?.Arguments.FirstOrDefault()?.Expression.ToString() ?? string.Empty; + + var getNode = new GetNode + { + VarName = varName, + Name = "Get:" + varName + }; + Log.Debug("Created GetNode: VarName={VarName}, Id={NodeId}", varName, getNode.Id); + + var getValuePin = getNode.OutputPins.First(p => p.Name == "Value"); + + if (assignment != null) + { + var assignedVarName = assignment.Left.ToString(); + if (assignedVarName != "_") + { + var isPubVar = context.Script.PubVarBlock?.Variables.Any(v => v.Name == assignedVarName) == true; + context.VariableSources["__get_" + assignedVarName + "_" + Guid.NewGuid().ToString("N") + "__"] = new VariableSource + { + Node = getNode, + Pin = getValuePin, + PubVarName = isPubVar ? assignedVarName : null + }; + Log.Debug("Registered GetNode output as VariableSource: {VarName}, IsPubVar={IsPubVar}", + assignedVarName, isPubVar); + } + } + + return getNode; + } + + if (funcName == "Set") + { + var argList = args?.Arguments.ToList() ?? new List(); + if (argList.Count >= 2) + { + var varName = argList[0].Expression.ToString(); + var valueExpr = argList[1].Expression; + var setNode = new SetNode + { + VarName = varName, + Name = "Set:" + varName + }; + Log.Debug("Created SetNode: VarName={VarName}, Id={NodeId}", varName, setNode.Id); + + var setValuePin = setNode.InputPins.FirstOrDefault(p => p.Name == "Value"); + if (setValuePin != null && valueExpr != null) + { + _connectionCreationService.ProcessArgumentExpression(valueExpr, setValuePin, setNode, context); + } + + return setNode; + } + return null; + } + + if (funcName == "Print") + { + var printNode = new PrintNode(); + var arg = args?.Arguments.FirstOrDefault()?.Expression.ToString() ?? string.Empty; + _connectionCreationService.CreateDataConnectionsForExpression(arg, printNode, context); + return printNode; + } + + if (funcName == "Pause") + { + var pauseNode = new PauseNode(); + var arg = args?.Arguments.FirstOrDefault()?.Expression.ToString() ?? string.Empty; + _connectionCreationService.CreateDataConnectionsForExpression(arg, pauseNode, context); + return pauseNode; + } + + if (assignment != null) + { + var varName = assignment.Left.ToString(); + + if (funcName is "Loop" or "Branch" or "LoopBodyEnd" or "Break") + { + Log.Debug("Skipped creating CallNode for flow control function: {FuncName}", funcName); + return null; + } + + var existingSource = context.VariableSources.Values + .FirstOrDefault(s => s.PubVarName == varName); + + BlueprintNode callNode; + if (existingSource != null) + { + callNode = existingSource.Node; + Log.Debug("Reused existing source node {NodeName} for {VarName}", callNode.Name, varName); + } + else + { + var isHelper = context.Script.HelperFunctions.Any(h => h.Name == funcName); + if (isHelper) + callNode = new CallHelperNode { HelperFunctionName = funcName, Name = "Helper:" + funcName }; + else + callNode = new CallNode { FunctionName = funcName, Name = "Call:" + funcName }; + + _connectionCreationService.AddParameterPins(callNode, args?.Arguments); + _connectionCreationService.CreateDataConnectionsForArguments(args?.Arguments, callNode, context); + + var returnPin = callNode.OutputPins.FirstOrDefault(p => p.Name == "Return"); + if (returnPin != null) + { + var isPubVar = context.Script.PubVarBlock?.Variables.Any(v => v.Name == varName) == true; + context.VariableSources[varName] = new VariableSource + { + Node = callNode, + Pin = returnPin, + PubVarName = isPubVar ? varName : null + }; + } + } + + return callNode; + } + + if (funcName is "Loop" or "Branch" or "LoopBodyEnd" or "Break") + { + Log.Debug("Skipped creating CallNode for flow control/builtin function: {FuncName}", funcName); + return null; + } + + BlueprintNode callNode2; + var isHelper2 = context.Script.HelperFunctions.Any(h => h.Name == funcName); + if (isHelper2) + callNode2 = new CallHelperNode { HelperFunctionName = funcName, Name = "Helper:" + funcName }; + else + callNode2 = new CallNode { FunctionName = funcName, Name = "Call:" + funcName }; + + _connectionCreationService.AddParameterPins(callNode2, args?.Arguments); + _connectionCreationService.CreateDataConnectionsForArguments(args?.Arguments, callNode2, context); + + return callNode2; + } + + private (string? funcName, ArgumentListSyntax? args, AssignmentExpressionSyntax? assignment)? TryParseInvocationExpression(string expression) + { + var wrappedCode = "_ = " + expression + ";"; + var syntaxTree = CSharpSyntaxTree.ParseText(wrappedCode, cancellationToken: CancellationToken.None); + var root = syntaxTree.GetCompilationUnitRoot(); + + var globalStmt = root.Members.FirstOrDefault() as GlobalStatementSyntax; + var stmt = globalStmt?.Statement as ExpressionStatementSyntax; + + if (stmt?.Expression is AssignmentExpressionSyntax outerAssignment) + { + ExpressionSyntax rightExpr = outerAssignment.Right; + AssignmentExpressionSyntax? innermostAssignment = null; + while (rightExpr is AssignmentExpressionSyntax nestedAssignment) + { + innermostAssignment = nestedAssignment; + rightExpr = nestedAssignment.Right; + } + + if (rightExpr is InvocationExpressionSyntax invoke) + { + var methodName = _connectionCreationService.GetMethodNameFromExpression(invoke); + var assignmentToReturn = innermostAssignment ?? outerAssignment; + return (methodName, invoke.ArgumentList, assignmentToReturn); + } + } + else if (stmt?.Expression is InvocationExpressionSyntax directInvoke) + { + var methodName = _connectionCreationService.GetMethodNameFromExpression(directInvoke); + return (methodName, directInvoke.ArgumentList, null); + } + return null; + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/IConnectionCreationService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/IConnectionCreationService.cs new file mode 100644 index 00000000..f0de518c --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/IConnectionCreationService.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using KitX.Core.Contract.Workflow; + +namespace KitX.Core.Workflow.Blueprint; + +public interface IConnectionCreationService +{ + void CreateExecConnection(BlueprintNode source, BlueprintNode target, Contract.Workflow.Blueprint blueprint); + + void CreateDataConnection(BlueprintNode source, string sourcePin, BlueprintNode target, string targetPin, string? pubVarName, Contract.Workflow.Blueprint blueprint); + + void LinkNodesWithExec(List nodes, Contract.Workflow.Blueprint blueprint); + + void CreateDataConnectionsForExpression(string expression, BlueprintNode targetNode, ConversionContext context); + + void CreateDataConnectionsForArguments(SeparatedSyntaxList? arguments, BlueprintNode callNode, ConversionContext context); + + void AddParameterPins(BlueprintNode node, SeparatedSyntaxList? arguments); + + void CreateDataConnectionForVariable(string varName, BlueprintPin targetPin, BlueprintNode callNode, ConversionContext context); + + void CreateDataConnectionForVariable(string varName, BlueprintNode targetNode, ConversionContext context); + + void ProcessArgumentExpression(ExpressionSyntax expr, BlueprintPin targetPin, BlueprintNode callNode, ConversionContext context); + + bool IsVariableReference(string token); + + void CreateConstNodeForLiteral(LiteralExpressionSyntax literal, BlueprintNode targetNode, ConversionContext context); + + void CreateConstNodeForLiteralWithPin(LiteralExpressionSyntax literal, BlueprintPin targetPin, BlueprintNode targetNode, ConversionContext context); + + string GetMethodNameFromExpression(InvocationExpressionSyntax invoke); +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/IFlowProcessingService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/IFlowProcessingService.cs new file mode 100644 index 00000000..7e7f56cd --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/IFlowProcessingService.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using KitX.Core.Contract.Workflow; + +namespace KitX.Core.Workflow.Blueprint; + +public interface IFlowProcessingService +{ + void ProcessConstBlock(BlockDefinition block, ConversionContext context); + + void ProcessPubVarBlock(BlockDefinition block, ConversionContext context); + + void ProcessMainBlock(BlockDefinition block, ConversionContext context, INodeCreationService nodeCreationService); + + BlueprintNode? ProcessStatement(BlockStatement statement, ConversionContext context, INodeCreationService nodeCreationService, double x, double y); + + BlueprintNode? ProcessFlowControlStatement(FlowControlStatement flowCtrl, ConversionContext context, INodeCreationService nodeCreationService, double x, double y); + + BlueprintNode? ProcessBlockRecursive(BlockDefinition block, ConversionContext context, INodeCreationService nodeCreationService, double x, double y); + + void ProcessBranchNodeConnections(BranchNode branch, BlockDefinition block, ConversionContext context, double x, double y); + + void ProcessBranchNodeConnectionsInMain(BranchNode branch, FlowControlStatement flowCtrl, ConversionContext context); + + void ProcessLoopNodeConnections(LoopNode loop, BlockDefinition block, ConversionContext context, double x, double y); + + void ProcessLoopNodeConnectionsInMain(LoopNode loop, FlowControlStatement flowCtrl, ConversionContext context); + + void ProcessLoopBlock(BlockDefinition block, ConversionContext context, INodeCreationService nodeCreationService); + + void HandleLoopBodyEnd(FlowControlStatement flowCtrl, ConversionContext context); + + BlueprintNode? GetFirstConnectableNode(BlueprintNode node, ConversionContext context); +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/LayoutService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/LayoutService.cs new file mode 100644 index 00000000..072fc14c --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/LayoutService.cs @@ -0,0 +1,106 @@ +using System.Collections.Generic; +using System.Linq; +using KitX.Core.Contract.Workflow; + +namespace KitX.Core.Workflow.Blueprint; + +public class LayoutService : ILayoutService +{ + public void LayoutNodes(Contract.Workflow.Blueprint blueprint) + { + var depths = new Dictionary(); + var visited = new HashSet(); + + // Phase 1: Calculate depths along exec chain + var entry = blueprint.Nodes.FirstOrDefault(n => n.NodeType == BlueprintNodeType.Entry); + if (entry != null) + { + depths[entry.Id] = 0; + CalculateExecDepths(blueprint, entry, depths, visited); + } + + // Phase 2: Place data nodes (Const etc.) at their consumer's depth + foreach (var node in blueprint.Nodes) + { + if (depths.ContainsKey(node.Id)) continue; + + var firstConn = blueprint.Connections + .FirstOrDefault(c => c.SourceNodeId == node.Id); + if (firstConn != null && depths.ContainsKey(firstConn.TargetNodeId)) + { + depths[node.Id] = depths[firstConn.TargetNodeId]; + } + } + + // Phase 3: Remaining unplaced nodes appended after max depth + var maxDepth = depths.Values.Count > 0 ? depths.Values.Max() : 0; + foreach (var node in blueprint.Nodes) + { + if (!depths.ContainsKey(node.Id)) + { + depths[node.Id] = ++maxDepth; + } + } + + // Phase 4: Group by depth + assign coordinates + var depthGroups = new Dictionary>(); + foreach (var node in blueprint.Nodes) + { + var depth = depths[node.Id]; + if (!depthGroups.ContainsKey(depth)) + depthGroups[depth] = new List(); + depthGroups[depth].Add(node); + } + + const double HSpacing = 220; + const double VSpacing = 130; + const double XOffset = 50; + const double YOffset = 50; + + foreach (var group in depthGroups.OrderBy(g => g.Key)) + { + var depth = group.Key; + var sorted = group.Value + .OrderBy(n => n.NodeType == BlueprintNodeType.Const ? 1 : 0) + .ToList(); + for (int i = 0; i < sorted.Count; i++) + { + sorted[i].X = XOffset + depth * HSpacing; + sorted[i].Y = YOffset + i * VSpacing; + } + } + } + + /// + /// Only traverse Execution-type connections for depth calculation, + /// so data-dependency edges don't pull nodes into wrong layers. + /// + private void CalculateExecDepths(Contract.Workflow.Blueprint blueprint, BlueprintNode node, + Dictionary depths, HashSet visited) + { + if (visited.Contains(node.Id)) return; + visited.Add(node.Id); + + // Find output pins that are Exec type (or named "Exec" / "True" / "False" / "LoopBody" / "LoopEnd") + var execOutPinIds = node.OutputPins + .Where(p => p.Type == PinType.Execution) + .Select(p => p.Id) + .ToHashSet(); + + var execConnections = blueprint.Connections.Where(c => + c.SourceNodeId == node.Id && execOutPinIds.Contains(c.SourcePinId)); + + foreach (var conn in execConnections) + { + if (blueprint.GetNodeById(conn.TargetNodeId) is { } targetNode) + { + var currentDepth = depths[node.Id]; + if (!depths.ContainsKey(targetNode.Id) || depths[targetNode.Id] < currentDepth + 1) + { + depths[targetNode.Id] = currentDepth + 1; + } + CalculateExecDepths(blueprint, targetNode, depths, visited); + } + } + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeCreationService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeCreationService.cs new file mode 100644 index 00000000..bb4943d6 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeCreationService.cs @@ -0,0 +1,162 @@ +using System; +using KitX.Core.Contract.Workflow; +using Serilog; + +namespace KitX.Core.Workflow.Blueprint; + +/// +/// Implementation of node creation service +/// +public class NodeCreationService : INodeCreationService +{ + /// + /// Creates a constant node + /// + public ConstNode CreateConstNode(string name, string type, string? defaultValue) + { + var constNode = new ConstNode + { + ConstName = name, + ConstType = type, + ConstValue = defaultValue ?? GetDefaultValueForType(type) + }; + Log.Debug("Created ConstNode: Name={ConstName}, Id={NodeId}", constNode.Name, constNode.Id); + return constNode; + } + + /// + /// Creates an entry node at the specified position + /// + public EntryNode CreateEntryNode(double x, double y) + { + var entryNode = new EntryNode + { + X = x, + Y = y + }; + Log.Debug("Created EntryNode: Name={NodeName}, Id={NodeId}", entryNode.Name, entryNode.Id); + return entryNode; + } + + /// + /// Creates a get variable node + /// + public GetNode CreateGetNode(string varName) + { + var getNode = new GetNode + { + VarName = varName, + Name = $"Get:{varName}" + }; + Log.Debug("Created GetNode: VarName={VarName}, Id={NodeId}", varName, getNode.Id); + return getNode; + } + + /// + /// Creates a set variable node + /// + public SetNode CreateSetNode(string varName) + { + var setNode = new SetNode + { + VarName = varName, + Name = $"Set:{varName}" + }; + Log.Debug("Created SetNode: VarName={VarName}, Id={NodeId}", varName, setNode.Id); + return setNode; + } + + /// + /// Creates a print node + /// + public PrintNode CreatePrintNode() + { + var printNode = new PrintNode(); + Log.Debug("Created PrintNode: Id={NodeId}", printNode.Id); + return printNode; + } + + /// + /// Creates a pause node + /// + public PauseNode CreatePauseNode() + { + var pauseNode = new PauseNode(); + Log.Debug("Created PauseNode: Id={NodeId}", pauseNode.Id); + return pauseNode; + } + + /// + /// Creates a call function node + /// + public CallNode CreateCallNode(string functionName) + { + var callNode = new CallNode + { + FunctionName = functionName, + Name = $"Call:{functionName}" + }; + Log.Debug("Created CallNode: FunctionName={FunctionName}, Id={NodeId}", functionName, callNode.Id); + return callNode; + } + + /// + /// Creates a call helper function node + /// + public CallHelperNode CreateCallHelperNode(string helperFunctionName) + { + var callHelperNode = new CallHelperNode + { + HelperFunctionName = helperFunctionName, + Name = $"Helper:{helperFunctionName}" + }; + Log.Debug("Created CallHelperNode: HelperFunctionName={HelperFunctionName}, Id={NodeId}", helperFunctionName, callHelperNode.Id); + return callHelperNode; + } + + /// + /// Creates a branch node + /// + public BranchNode CreateBranchNode() + { + var branchNode = new BranchNode(); + Log.Debug("Created BranchNode: Id={NodeId}", branchNode.Id); + return branchNode; + } + + /// + /// Creates a loop node + /// + public LoopNode CreateLoopNode() + { + var loopNode = new LoopNode(); + Log.Debug("Created LoopNode: Id={NodeId}", loopNode.Id); + return loopNode; + } + + /// + /// Creates a break node + /// + public BreakNode CreateBreakNode() + { + var breakNode = new BreakNode(); + Log.Debug("Created BreakNode: Id={NodeId}", breakNode.Id); + return breakNode; + } + + /// + /// Gets default value for a given type + /// + private static string GetDefaultValueForType(string type) + { + return type.ToLower() switch + { + "int" or "integer" or "long" or "short" or "byte" => "0", + "float" or "double" or "decimal" => "0", + "bool" or "boolean" => "false", + "string" => "", + "char" => "\0", + _ => "" + }; + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeTemplateProvider.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeTemplateProvider.cs index 495ce9f4..ed1064e0 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeTemplateProvider.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeTemplateProvider.cs @@ -153,6 +153,40 @@ public NodeTemplateProvider() [ new PinTemplate { Name = "Exec", Type = PinType.Execution, RelativeY = 25 } ] + }, + + [BlueprintNodeType.Get] = new NodeTemplate + { + NodeType = BlueprintNodeType.Get, + Name = "Get", + Width = 120, + Height = 60, + InputPins = + [ + new PinTemplate { Name = "Exec", Type = PinType.Execution, RelativeY = 20 } + ], + OutputPins = + [ + new PinTemplate { Name = "Exec", Type = PinType.Execution, RelativeY = 20 }, + new PinTemplate { Name = "Value", Type = PinType.Any, RelativeY = 40 } + ] + }, + + [BlueprintNodeType.Set] = new NodeTemplate + { + NodeType = BlueprintNodeType.Set, + Name = "Set", + Width = 120, + Height = 60, + InputPins = + [ + new PinTemplate { Name = "Exec", Type = PinType.Execution, RelativeY = 20 }, + new PinTemplate { Name = "Value", Type = PinType.Any, RelativeY = 40 } + ], + OutputPins = + [ + new PinTemplate { Name = "Exec", Type = PinType.Execution, RelativeY = 20 } + ] } }; } @@ -168,6 +202,8 @@ public NodeTemplateProvider() BlueprintNodeType.CallHelper => new CallHelperNode(), BlueprintNodeType.Print => new PrintNode(), BlueprintNodeType.Pause => new PauseNode(), + BlueprintNodeType.Get => new GetNode(), + BlueprintNodeType.Set => new SetNode(), _ => throw new ArgumentException($"Unknown node type: {type}") }; diff --git a/KitX Clients/KitX Dashboard b/KitX Clients/KitX Dashboard index 64049634..3c66964e 160000 --- a/KitX Clients/KitX Dashboard +++ b/KitX Clients/KitX Dashboard @@ -1 +1 @@ -Subproject commit 64049634edd8b5a629a8a246a1e13925ca4ee684 +Subproject commit 3c66964e1ba97104c1736931bb9bc826c755bee0 diff --git a/KitX Standard b/KitX Standard index 691241f1..cc7f85e6 160000 --- a/KitX Standard +++ b/KitX Standard @@ -1 +1 @@ -Subproject commit 691241f1572d4bd780772a8b637eae1a60a49fb5 +Subproject commit cc7f85e65b0a234c02e1026a7159dce6fd2635f2 From 33632c6b5463f17b39477b2da2be88326eacb4f7 Mon Sep 17 00:00:00 2001 From: StarInk Date: Sat, 4 Apr 2026 17:39:25 +0200 Subject: [PATCH 027/110] =?UTF-8?q?=F0=9F=92=BE=20Feat(Blueprint):=20Imple?= =?UTF-8?q?ment=20pipeline=20phases=20for=20Blueprint=20processing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added NodeBuilder class to create Blueprint nodes and execution flow edges from FormattedBlockScript. - Introduced PipelineAssembler class to assemble the final Blueprint from pipeline outputs, converting pending edges into connections. - Created PipelineContext class to maintain state across conversion phases, replacing the old ConversionContext. - Developed ScriptFormatter class to format BlockScript AST into FormattedBlockScript, expanding nested function calls and duplicating loop conditions. --- .../KitX.Core.BluePrint.Test/Program.cs | 432 ++++++-------- .../DI/CoreServiceCollectionExtensions.cs | 5 +- .../BlockScriptToBlueprintConverter.cs | 157 ++++-- .../Blueprint/BlueprintRenderDataService.cs | 68 +++ .../Workflow/Blueprint/BlueprintService.cs | 48 +- .../Blueprint/ConnectionCreationService.cs | 2 +- .../Blueprint/FlowProcessingService.cs | 9 +- .../Workflow/Blueprint/LayoutService.cs | 388 +++++++++++-- .../Blueprint/Pipeline/DataEdgeBuilder.cs | 259 +++++++++ .../Workflow/Blueprint/Pipeline/ExprUtils.cs | 162 ++++++ .../Pipeline/FormattedBlockScript.cs | 140 +++++ .../Blueprint/Pipeline/NodeBuilder.cs | 411 ++++++++++++++ .../Blueprint/Pipeline/PipelineAssembler.cs | 156 +++++ .../Blueprint/Pipeline/PipelineContext.cs | 138 +++++ .../Blueprint/Pipeline/ScriptFormatter.cs | 532 ++++++++++++++++++ KitX Clients/KitX Dashboard | 2 +- KitX Standard | 2 +- 17 files changed, 2493 insertions(+), 418 deletions(-) create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintRenderDataService.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/DataEdgeBuilder.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ExprUtils.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/FormattedBlockScript.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/NodeBuilder.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineAssembler.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineContext.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ScriptFormatter.cs diff --git a/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs b/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs index 56c43a15..db01cf36 100644 --- a/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs +++ b/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs @@ -12,297 +12,184 @@ public class Program { public static void Main(string[] args) { - Console.WriteLine("╔════════════════════════════════════════════════════════╗"); - Console.WriteLine("║ KitX Core - BlurPrint (Blueprint) Test ║"); - Console.WriteLine("╚════════════════════════════════════════════════════════╝\n"); + Console.WriteLine("=== KitX BlockScript → Blueprint Pipeline Test ===\n"); - // ============================================================ - // Step 1: Initialize DI Container - // ============================================================ + // DI Container var services = new ServiceCollection(); services.AddCoreServices(); - var serviceProvider = services.BuildServiceProvider(); - - // Get IBlockScriptParser from DI - var parser = serviceProvider.GetRequiredService(); - Console.WriteLine("✓ DI Container initialized"); - Console.WriteLine($"✓ IBlockScriptParser resolved: {parser.GetType().Name}\n"); - - // ============================================================ - // Step 2: Create Services for Conversion - // ============================================================ - var connectionCreationService = new ConnectionCreationService(); - var flowProcessingService = new FlowProcessingService(connectionCreationService); - var layoutService = new LayoutService(); - - // Note: IBlockScriptToBlueprintConverter is NOT in DI, manually instantiate - var converter = new BlockScriptToBlueprintConverter( - parser, - flowProcessingService, - connectionCreationService, - layoutService); - - // ============================================================ - // Step 3: Sample BlockScript Source Code - // ============================================================ - var sourceCode = GetSampleBlockScript(); - - // ============================================================ - // Step 4: Parse BlockScript - // ============================================================ - Console.WriteLine("┌─────────────────────────────────────────────────────────┐"); - Console.WriteLine("│ Parsing BlockScript │"); - Console.WriteLine("└─────────────────────────────────────────────────────────┘\n"); - - var parseResult = parser.Parse(sourceCode); - - if (!parseResult.IsSuccess || parseResult.Script == null) + var sp = services.BuildServiceProvider(); + + var parser = sp.GetRequiredService(); + var nodeFactory = sp.GetRequiredService(); + var layoutService = sp.GetRequiredService(); + + Console.WriteLine("DI initialized.\n"); + + // HelperFunctions + var helpers = new List { - Console.WriteLine($"✗ Parse failed: {parseResult.ErrorMessage}"); - Console.WriteLine($" Error at line: {parseResult.ErrorLine}"); - return; - } + new() + { + Name = "HelperFuncCompare", + Parameters = + [ + new() { Name = "op", Type = "string" }, + new() { Name = "left", Type = "int" }, + new() { Name = "right", Type = "int" } + ], + ReturnType = "bool" + }, + new() + { + Name = "HelperFuncAdd", + Parameters = + [ + new() { Name = "a", Type = "int" }, + new() { Name = "b", Type = "int" } + ], + ReturnType = "int" + } + }; - Console.WriteLine("✓ BlockScript parsed successfully\n"); + var converter = new BlockScriptToBlueprintConverter(parser, nodeFactory, layoutService); - var script = parseResult.Script; + // ── Test A: Pre-expanded format (already in test script) ── + Console.WriteLine("┌──────────────────────────────────────────┐"); + Console.WriteLine("│ Test A: Pre-expanded BlockScript │"); + Console.WriteLine("└──────────────────────────────────────────┘\n"); - // Display parsed structure - Console.WriteLine(" Parsed Structure:"); - Console.WriteLine($" - ConstBlock: {(script.ConstBlock != null ? script.ConstBlock.Name : "null")}"); - Console.WriteLine($" - PubVarBlock: {(script.PubVarBlock != null ? script.PubVarBlock.Name : "null")}"); - Console.WriteLine($" - MainBlock: {(script.MainBlock != null ? script.MainBlock.Name : "null")}"); - Console.WriteLine($" - NamedBlocks: {script.NamedBlocks.Count}"); - Console.WriteLine($" - LoopBlocks: {script.LoopBlocks.Count}"); - Console.WriteLine($" - HelperFunctions: {script.HelperFunctions.Count}\n"); + RunTest(converter, GetPreExpandedScript(), helpers, "Test A"); - // ============================================================ - // Step 5: Convert to Blueprint - // ============================================================ - Console.WriteLine("┌─────────────────────────────────────────────────────────┐"); - Console.WriteLine("│ Converting to Blueprint │"); - Console.WriteLine("└─────────────────────────────────────────────────────────┘\n"); + // ── Test B: Raw nested format ── + Console.WriteLine("\n┌──────────────────────────────────────────┐"); + Console.WriteLine("│ Test B: Raw nested BlockScript │"); + Console.WriteLine("└──────────────────────────────────────────┘\n"); - Contract.Workflow.Blueprint blueprint; - try - { - blueprint = converter.Convert(script); - Console.WriteLine("✓ Blueprint created successfully\n"); - } - catch (Exception ex) - { - Console.WriteLine($"✗ Conversion failed: {ex.Message}"); - Console.WriteLine($" Stack trace: {ex.StackTrace}"); - return; - } + RunTest(converter, GetRawNestedScript(), helpers, "Test B"); + } - // ============================================================ - // Step 6: Display Blueprint Summary - // ============================================================ - Console.WriteLine("┌─────────────────────────────────────────────────────────┐"); - Console.WriteLine("│ Blueprint Summary │"); - Console.WriteLine("└─────────────────────────────────────────────────────────┘\n"); - - Console.WriteLine($" Name: {blueprint.Name}"); - Console.WriteLine($" ID: {blueprint.Id}"); - Console.WriteLine($" Nodes: {blueprint.Nodes.Count}"); - Console.WriteLine($" Connections: {blueprint.Connections.Count}"); - Console.WriteLine($" HelperFunctions: {blueprint.HelperFunctions.Count}"); - Console.WriteLine($" PubVarNames: {blueprint.PubVarNames.Count}"); - Console.WriteLine($" ConstValues: {blueprint.ConstValues.Count}\n"); - - // ============================================================ - // Step 7: Display HelperFunctions - // ============================================================ - if (blueprint.HelperFunctions.Count > 0) + private static void RunTest(BlockScriptToBlueprintConverter converter, + string sourceCode, List helpers, string label) + { + try { - Console.WriteLine("┌─────────────────────────────────────────────────────────┐"); - Console.WriteLine("│ HelperFunctions │"); - Console.WriteLine("└─────────────────────────────────────────────────────────┘\n"); + var blueprint = converter.Convert(sourceCode, helpers); - foreach (var helper in blueprint.HelperFunctions) + // ── Phase 2 Debug: Dump formatted script ── + if (converter.LastContext?.FormattedScript != null) { - Console.WriteLine($" • {helper.Name}"); - if (helper.Parameters.Count > 0) + Console.WriteLine($" ── Formatted Script ({label}) ──"); + var formatted = converter.LastContext.FormattedScript; + foreach (var block in formatted.Blocks) { - var @params = string.Join(", ", helper.Parameters.Select(p => $"{p.Type} {p.Name}")); - Console.WriteLine($" Parameters: {@params}"); + Console.WriteLine($" #Block {block.Name} (NextBlock={block.NextBlockName ?? "null"})"); + foreach (var stmt in block.Statements) + { + var args = stmt.Arguments != null ? string.Join(", ", stmt.Arguments) : ""; + var dup = stmt.IsLoopConditionDuplication ? " [LOOP_COND_DUP]" : ""; + Console.WriteLine($" [{stmt.Kind}] {stmt.OriginalExpression}"); + Console.WriteLine($" PubVarTarget={stmt.PubVarTarget} Func={stmt.FunctionName} Args=[{args}]"); + Console.WriteLine($" SetVar={stmt.SetVarName} GetVar={stmt.GetVarName} Fingerprint={stmt.Fingerprint}{dup}"); + } + Console.WriteLine(); } + Console.WriteLine($" ── End Formatted Script ({label}) ──\n"); } - Console.WriteLine(); - } - // ============================================================ - // Step 8: Display PubVarNames - // ============================================================ - if (blueprint.PubVarNames.Count > 0) - { - Console.WriteLine("┌─────────────────────────────────────────────────────────┐"); - Console.WriteLine("│ PubVarNames (Public Variables) │"); - Console.WriteLine("└─────────────────────────────────────────────────────────┘\n"); + Console.WriteLine($"[{label}] Success!"); + Console.WriteLine($" Nodes: {blueprint.Nodes.Count}"); + Console.WriteLine($" Connections: {blueprint.Connections.Count}"); + + // Count by node type + var byType = blueprint.Nodes.GroupBy(n => n.NodeType) + .OrderBy(g => g.Key.ToString()) + .Select(g => $"{g.Key}={g.Count()}"); + Console.WriteLine($" Node types: {string.Join(", ", byType)}"); - foreach (var pubVar in blueprint.PubVarNames) + // Count exec vs data connections + int execConns = 0, dataConns = 0; + foreach (var conn in blueprint.Connections) { - Console.WriteLine($" • {pubVar}"); + var srcNode = blueprint.GetNodeById(conn.SourceNodeId); + var srcPin = srcNode?.OutputPins.FirstOrDefault(p => p.Id == conn.SourcePinId); + if (srcPin?.Type == PinType.Execution) execConns++; + else dataConns++; } - Console.WriteLine(); - } + Console.WriteLine($" Exec connections: {execConns}"); + Console.WriteLine($" Data connections: {dataConns}"); - // ============================================================ - // Step 9: Display ConstValues - // ============================================================ - if (blueprint.ConstValues.Count > 0) - { - Console.WriteLine("┌─────────────────────────────────────────────────────────┐"); - Console.WriteLine("│ ConstValues (Variable Constants) │"); - Console.WriteLine("└─────────────────────────────────────────────────────────┘\n"); + // PubVarNames + Console.WriteLine($" PubVarNames: [{string.Join(", ", blueprint.PubVarNames)}]"); - foreach (var vc in blueprint.ConstValues) + // ConstValues + if (blueprint.ConstValues.Count > 0) { - Console.WriteLine($" • {vc.Name}: {vc.Type} = {vc.DefaultValue}"); + Console.WriteLine(" ConstValues:"); + foreach (var cv in blueprint.ConstValues) + Console.WriteLine($" {cv.Name} = {cv.DefaultValue}"); } - Console.WriteLine(); - } - - // ============================================================ - // Step 10: Display Nodes by Type - // ============================================================ - Console.WriteLine("┌─────────────────────────────────────────────────────────┐"); - Console.WriteLine("│ Nodes (Grouped by Type) │"); - Console.WriteLine("└─────────────────────────────────────────────────────────┘\n"); - - var nodesByType = blueprint.Nodes - .GroupBy(n => n.NodeType) - .OrderBy(g => g.Key.ToString()); - foreach (var group in nodesByType) - { - Console.WriteLine($" [{group.Key}] ({group.Count()} nodes)"); - foreach (var node in group) + // Detailed exec connections + Console.WriteLine("\n Exec Edges:"); + foreach (var conn in blueprint.Connections) { - var inputPins = string.Join(", ", - node.InputPins.Select(p => $"{p.Name}({p.Direction}, {p.Type})")); - var outputPins = string.Join(", ", - node.OutputPins.Select(p => $"{p.Name}({p.Direction}, {p.Type})")); - - // Type-specific extra info - var extra = node switch + var srcNode = blueprint.GetNodeById(conn.SourceNodeId); + var tgtNode = blueprint.GetNodeById(conn.TargetNodeId); + var srcPin = srcNode?.OutputPins.FirstOrDefault(p => p.Id == conn.SourcePinId); + var tgtPin = tgtNode?.InputPins.FirstOrDefault(p => p.Id == conn.TargetPinId); + if (srcPin?.Type == PinType.Execution) { - ConstNode cn => $" | ConstName={cn.ConstName}, Value={cn.ConstValue}", - CallNode call => $" | Function={call.FunctionName}", - CallHelperNode helper => $" | Helper={helper.HelperFunctionName}", - GetNode gn => $" | VarName={gn.VarName}", - SetNode sn => $" | VarName={sn.VarName}", - BranchNode bn => $" | Type=Branch", - LoopNode ln => $" | Type=Loop", - EntryNode en => $" | Type=Entry", - BreakNode brk => $" | Type=Break", - PrintNode pn => $" | Type=Print", - PauseNode pause => $" | Type=Pause", - _ => "" - }; - - Console.WriteLine($" • {node.Name} ({node.Id})"); - - // 显示输入引脚,包含预设值 - var inputPinsWithDefault = string.Join(", ", - node.InputPins.Select(p => p.DefaultValue != null - ? $"{p.Name}({p.Direction}, {p.Type}) = \"{p.DefaultValue}\"" - : $"{p.Name}({p.Direction}, {p.Type})")); - Console.WriteLine($" Inputs: [{inputPinsWithDefault}]"); - - // 显示输出引脚 - var outputPinsWithDefault = string.Join(", ", - node.OutputPins.Select(p => p.DefaultValue != null - ? $"{p.Name}({p.Direction}, {p.Type}) = \"{p.DefaultValue}\"" - : $"{p.Name}({p.Direction}, {p.Type})")); - Console.WriteLine($" Outputs: [{outputPinsWithDefault}]{extra}"); + Console.WriteLine($" {srcNode?.Name}.{srcPin?.Name} -> {tgtNode?.Name}.{tgtPin?.Name}"); + } } - Console.WriteLine(); - } - // ============================================================ - // Step 11: Display Connections (Exec vs Data) - // ============================================================ - Console.WriteLine("┌─────────────────────────────────────────────────────────┐"); - Console.WriteLine("│ Connections │"); - Console.WriteLine("└─────────────────────────────────────────────────────────┘\n"); - - var execConnections = new List(); - var dataConnections = new List(); - - foreach (var conn in blueprint.Connections) - { - var sourceNode = blueprint.GetNodeById(conn.SourceNodeId); - var targetNode = blueprint.GetNodeById(conn.TargetNodeId); - var sourcePin = sourceNode?.OutputPins.FirstOrDefault(p => p.Id == conn.SourcePinId); - var targetPin = targetNode?.InputPins.FirstOrDefault(p => p.Id == conn.TargetPinId); - - if (sourcePin?.Type == PinType.Execution || targetPin?.Type == PinType.Execution) - execConnections.Add(conn); - else - dataConnections.Add(conn); - } - - Console.WriteLine($" Exec Connections: {execConnections.Count}"); - foreach (var conn in execConnections) - { - var src = blueprint.GetNodeById(conn.SourceNodeId); - var tgt = blueprint.GetNodeById(conn.TargetNodeId); - var sp = src?.OutputPins.FirstOrDefault(p => p.Id == conn.SourcePinId); - var tp = tgt?.InputPins.FirstOrDefault(p => p.Id == conn.TargetPinId); - Console.WriteLine($" • {src?.Name}[{src?.Id.Substring(0,8)}].{sp?.Name} -> {tgt?.Name}[{tgt?.Id.Substring(0,8)}].{tp?.Name}"); + // Detailed data connections + Console.WriteLine("\n Data Edges:"); + foreach (var conn in blueprint.Connections) + { + var srcNode = blueprint.GetNodeById(conn.SourceNodeId); + var tgtNode = blueprint.GetNodeById(conn.TargetNodeId); + var srcPin = srcNode?.OutputPins.FirstOrDefault(p => p.Id == conn.SourcePinId); + var tgtPin = tgtNode?.InputPins.FirstOrDefault(p => p.Id == conn.TargetPinId); + if (srcPin?.Type != PinType.Execution) + { + var pv = conn.PubVarName != null ? $" [PubVar={conn.PubVarName}]" : ""; + Console.WriteLine($" {srcNode?.Name}.{srcPin?.Name} -> {tgtNode?.Name}.{tgtPin?.Name}{pv}"); + } + } } - - Console.WriteLine($"\n Data Connections: {dataConnections.Count}"); - foreach (var conn in dataConnections) + catch (Exception ex) { - var src = blueprint.GetNodeById(conn.SourceNodeId); - var tgt = blueprint.GetNodeById(conn.TargetNodeId); - var sp = src?.OutputPins.FirstOrDefault(p => p.Id == conn.SourcePinId); - var tp = tgt?.InputPins.FirstOrDefault(p => p.Id == conn.TargetPinId); - var pubvar = conn.PubVarName != null ? $" (PubVar: {conn.PubVarName})" : ""; - Console.WriteLine($" • {src?.Name}[{src?.Id.Substring(0,8)}].{sp?.Name} -> {tgt?.Name}[{tgt?.Id.Substring(0,8)}].{tp?.Name}{pubvar}"); + Console.WriteLine($"[{label}] FAILED: {ex.Message}"); + Console.WriteLine($" {ex.StackTrace}"); } - - Console.WriteLine("\n═════════════════════════════════════════════════════════"); - Console.WriteLine(" Test Complete"); - Console.WriteLine("═════════════════════════════════════════════════════════\n"); } - private static string GetSampleBlockScript() - { - string sourceCode = @"#ConstBlock -int guessNum = 5; // 可变常量,用户在UI中可修改 + // ────────────────────────────────────────────── + // Test A: Pre-expanded format (already has PubVar assignments) + // ────────────────────────────────────────────── + private static string GetPreExpandedScript() => @"#ConstBlock +int guessNum = 5; int loopMax = 3; int targetNum = 7; -int currentLoop; // 无预赋值(值初始化),用户无法在UI中修改 -// 这个currentLoop为什么不能放在PubVarBlock中: -// 它不是“一次性”的“边数据承载”变量,它是多处、多次使用且随运行而需要变化并持久存储的变量,它的最短生命周期远长于PubVarBlock中的一次性变量(赋值-使用1次后即可销毁,下次用到再重新创建) +int currentLoop; #PubVarBlock -// 自动生成,为蓝图预留(是那些数据边为了临时承载数据而使用的变量) -// 除非你知道自己在做什么并且完全了解块脚本与蓝图互译的过程,否则不要在这个块中添加、删除或修改代码 -// 直接编写BlockScript时不需要在这里设置变量 bool vaaa0001; int vaaa0002; #MainBlock Print(""开始执行工作流""); Set(""currentLoop"", 0); -// NextBlock = Loop(HelperFuncCompare(""BLE"", Get(""currentLoop""), loopMax), ""LoopBody"", ""EndLogic""); // 转化前的语句(有嵌套调用) -vaaa0001 = HelperFuncCompare(""BLE"", Get(""currentLoop""), loopMax); // 脚本转蓝图后,再转回块脚本时,就会利用PubVarBlock中生成的“临时变量”来生成这样的语句(拆分嵌套调用) -NextBlock = Loop(vaaa0001, ""LoopBody"", ""EndLogic""); // 主循环 +vaaa0001 = HelperFuncCompare(""BLE"", Get(""currentLoop""), loopMax); +NextBlock = Loop(vaaa0001, ""LoopBody"", ""EndLogic""); #Block LoopBody -// Print(Get(currentLoop)); // 原始嵌套调用用法 -// vaaa0001 = Get(currentLoop); -// Print(vaaa0001); // 其实逻辑上等价但是不推荐的方案──反正PubVarBlock的变量池是“无限大”的,没必要重复使用同一个变量。 vaaa0002 = Get(""currentLoop""); -Print(vaaa0002); // 更优的做法,每个临时变量实际上绑定了一条数据边。这样也方便后续直接对蓝图脚本进行Debug时监测数据边上的数据 -Set(""currentLoop"", HelperFuncAdd(Get(""currentLoop""), 1)); // 函数嵌套调用,记得在默认的HelperFunction初始化程序中添加这个HelperFuncAdd +Print(vaaa0002); +Set(""currentLoop"", HelperFuncAdd(Get(""currentLoop""), 1)); NextBlock = Branch( - HelperFuncCompare(""BEQ"", guessNum, targetNum), // ✅ 函数调用 + HelperFuncCompare(""BEQ"", guessNum, targetNum), ""SuccessLogic"", ""CheckLogic"" ); @@ -315,21 +202,62 @@ private static string GetSampleBlockScript() ); #Block LessThanLogic -Print(""猜小了""); // 其实用Print()也行 -vaaa0001 = HelperFuncCompare(""BLE"", Get(""currentLoop""), loopMax); // 原先Loop中的内置嵌套condition表达式由于被拆分,需要在LoopBodyEnd被调用前进行结算,以保持逻辑一致性 -NextBlock = LoopBodyEnd(""MainBlock""); // 返回到 MainBlock 的 Loop +Print(""猜小了""); +vaaa0001 = HelperFuncCompare(""BLE"", Get(""currentLoop""), loopMax); +NextBlock = LoopBodyEnd(""MainBlock""); #Block GreaterThanLogic Print(""猜大了""); -vaaa0001 = HelperFuncCompare(""BLE"", Get(""currentLoop""), loopMax); // 原先Loop中的内置嵌套condition表达式由于被拆分,需要在LoopBodyEnd被调用前进行结算,以保持逻辑一致性 -// 这里仍是vaaa0001是因为在蓝图中实际上是一条边:CallHelper:HelperFuncCompare(BLE).Return --> Loop.Condition | PubVar=vaaa0001 -NextBlock = LoopBodyEnd(""MainBlock""); // 返回到 MainBlock 的 Loop +vaaa0001 = HelperFuncCompare(""BLE"", Get(""currentLoop""), loopMax); +NextBlock = LoopBodyEnd(""MainBlock""); #Block SuccessLogic -Print(""猜对啦!""); // 这个Block没有Branch/Loop/LoopBodyEnd,自然进入下一行 +Print(""猜对啦!""); + +#Block EndLogic +Print(""示例工作流结束"");"; + + // ────────────────────────────────────────────── + // Test B: Raw nested format (ScriptFormatter must expand) + // ────────────────────────────────────────────── + private static string GetRawNestedScript() => @"#ConstBlock +int guessNum = 5; +int loopMax = 3; +int targetNum = 7; +int currentLoop; + +#MainBlock +Print(""开始执行工作流""); +Set(""currentLoop"", 0); +NextBlock = Loop(HelperFuncCompare(""BLE"", Get(""currentLoop""), loopMax), ""LoopBody"", ""EndLogic""); + +#Block LoopBody +Print(Get(""currentLoop"")); +Set(""currentLoop"", HelperFuncAdd(Get(""currentLoop""), 1)); +NextBlock = Branch( + HelperFuncCompare(""BEQ"", guessNum, targetNum), + ""SuccessLogic"", + ""CheckLogic"" +); + +#Block CheckLogic +NextBlock = Branch( + HelperFuncCompare(""BLT"", guessNum, targetNum), + ""LessThanLogic"", + ""GreaterThanLogic"" +); + +#Block LessThanLogic +Print(""猜小了""); +NextBlock = LoopBodyEnd(""MainBlock""); + +#Block GreaterThanLogic +Print(""猜大了""); +NextBlock = LoopBodyEnd(""MainBlock""); + +#Block SuccessLogic +Print(""猜对啦!""); #Block EndLogic Print(""示例工作流结束"");"; - return sourceCode; - } } diff --git a/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs b/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs index 8a6bdd27..a7af21e4 100644 --- a/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs +++ b/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs @@ -199,10 +199,9 @@ public static IServiceCollection AddCoreServices(this IServiceCollection service // Blueprint Sub-services (must be registered before IBlueprintService) Log.Information("Registering Blueprint sub-services..."); - services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); - // INodeCreationService: no implementation class yet, to be added later + services.AddSingleton(); // Blueprint Services Log.Information("Registering IBlueprintService..."); diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlockScriptToBlueprintConverter.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlockScriptToBlueprintConverter.cs index f966dad9..8e0b6026 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlockScriptToBlueprintConverter.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlockScriptToBlueprintConverter.cs @@ -1,34 +1,37 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Threading; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; +using KitX.Core.Workflow.Blueprint.Pipeline; +using Serilog; +using KitX.Core.Contract.Workflow; +using KitX.Core.Workflow.Blueprint.Pipeline; using Serilog; namespace KitX.Core.Workflow.Blueprint; /// -/// Converts BlockScript to Blueprint +/// Converts BlockScript to Blueprint via a clean 6-phase pipeline. +/// Acts as a thin orchestrator — all logic lives in individual pipeline phases. /// public class BlockScriptToBlueprintConverter : IBlockScriptToBlueprintConverter { private readonly IBlockScriptParser _parser; - private readonly IFlowProcessingService _flowProcessingService; - private readonly IConnectionCreationService _connectionCreationService; + private readonly INodeCreationService _nodeFactory; private readonly ILayoutService _layoutService; + /// + /// The pipeline context from the last conversion (for debug inspection). + /// + public PipelineContext? LastContext { get; private set; } + public BlockScriptToBlueprintConverter( IBlockScriptParser parser, - IFlowProcessingService flowProcessingService, - IConnectionCreationService connectionCreationService, + INodeCreationService nodeFactory, ILayoutService layoutService) { _parser = parser; - _flowProcessingService = flowProcessingService; - _connectionCreationService = connectionCreationService; + _nodeFactory = nodeFactory; _layoutService = layoutService; } @@ -36,71 +39,113 @@ public Contract.Workflow.Blueprint Convert(string sourceCode, List() - }; + var helpers = script.HelperFunctions ?? new List(); - var context = new ConversionContext + // ── Phase 1: ConstBlock + PubVarBlock processing ── + var context = new PipelineContext { - Blueprint = blueprint, Script = script, - NodeMap = new Dictionary(), - NextPubVarIndex = 0, - NamedBlockMap = new Dictionary(), - BlockFirstNodes = new Dictionary(), - VisitedBlocks = new HashSet(), - VariableSources = new Dictionary(), - LoopNodesByParentBlock = new Dictionary(), - PendingNodesForExecChain = new List() + HelperFunctions = helpers }; - // Process ConstBlock - if (script.ConstBlock != null) - { - _flowProcessingService.ProcessConstBlock(script.ConstBlock, context); - } + Phase1_ProcessConstAndPubVar(context); + Log.Debug("[Converter] Phase 1: {ConstCount} const nodes, {PubVarCount} pub vars", + context.ConstNodes.Count, context.PubVarNames.Count); - // Process PubVarBlock - if (script.PubVarBlock != null) - { - _flowProcessingService.ProcessPubVarBlock(script.PubVarBlock, context); - } + // ── Phase 2: Script formatting (expand nested calls + loop condition duplication) ── + var formatter = new ScriptFormatter(helpers); + context.FormattedScript = formatter.Format(script, context); + Log.Debug("[Converter] Phase 2: {BlockCount} blocks, {StmtCount} statements", + context.FormattedScript.Blocks.Count, + context.FormattedScript.Blocks.Sum(b => b.Statements.Count)); - // Process MainBlock - if (script.MainBlock != null) - { - _flowProcessingService.ProcessMainBlock(script.MainBlock, context, null); - } + // ── Phase 3: Node creation + exec edges + PubVar reuse ── + var nodeBuilder = new NodeBuilder(_nodeFactory, helpers); + nodeBuilder.Build(context.FormattedScript, context); + Log.Debug("[Converter] Phase 3: {NodeCount} nodes, {ExecEdgeCount} exec edges", + context.AllNodes.Count, context.ExecEdges.Count); - // Process NamedBlocks - foreach (var kvp in script.NamedBlocks) - { - context.NamedBlockMap[kvp.Key] = kvp.Value; - } + // ── Phase 4+5: Data edges + deduplication ── + var dataEdgeBuilder = new DataEdgeBuilder(); + dataEdgeBuilder.Build(context); + Log.Debug("[Converter] Phase 4+5: {DataEdgeCount} data edges", context.DataEdges.Count); - // Process LoopBlocks - foreach (var kvp in script.LoopBlocks) - { - _flowProcessingService.ProcessLoopBlock(kvp.Value, context, null); - } + // ── Phase 6: Assemble Blueprint ── + var assembler = new PipelineAssembler(); + var blueprint = assembler.Assemble(context); - // Auto-layout nodes + // ── Layout ── _layoutService.LayoutNodes(blueprint); - Log.Information("[BlueprintConversion] Complete: {NodeCount} nodes, {ConnectionCount} connections", + LastContext = context; + + Log.Information("[Converter] Complete: {NodeCount} nodes, {ConnCount} connections", blueprint.Nodes.Count, blueprint.Connections.Count); return blueprint; } + + // ────────────────────────────────────────────── + // Phase 1: ConstBlock + PubVarBlock + // ────────────────────────────────────────────── + + private void Phase1_ProcessConstAndPubVar(PipelineContext context) + { + // Process ConstBlock variables → ConstNodes + if (context.Script.ConstBlock != null) + { + foreach (var varDecl in context.Script.ConstBlock.Variables) + { + var value = varDecl.DefaultValue?.ToString() ?? varDecl.InitialValueExpression ?? ""; + var constNode = _nodeFactory.CreateConstNode(varDecl.Name, varDecl.Type, value); + context.ConstNodes[varDecl.Name] = constNode; + context.AllNodes.Add(constNode); + } + } + + // Process PubVarBlock variables → PubVarNames + if (context.Script.PubVarBlock != null) + { + foreach (var varDecl in context.Script.PubVarBlock.Variables) + { + if (!context.PubVarNames.Contains(varDecl.Name)) + context.PubVarNames.Add(varDecl.Name); + } + } + } + + /// + /// Dumps the formatted script (Phase 2 output) as a human-readable string. + /// + public static string DumpFormattedScript(PipelineContext context) + { + var sb = new System.Text.StringBuilder(); + foreach (var block in context.FormattedScript.Blocks) + { + sb.AppendLine($"#Block {block.Name} (NextBlock={block.NextBlockName ?? "null"})"); + foreach (var stmt in block.Statements) + { + var dup = stmt.IsLoopConditionDuplication ? " [LoopCondDup]" : ""; + var fp = stmt.Fingerprint != null ? $" FP={stmt.Fingerprint}" : ""; + var args = stmt.Arguments != null ? string.Join(", ", stmt.Arguments) : ""; + sb.AppendLine($" [{stmt.Kind}] {stmt.OriginalExpression}" + + $" | PubVarTarget={stmt.PubVarTarget} Func={stmt.FunctionName}" + + $" Args=[{args}] SetVar={stmt.SetVarName} GetVar={stmt.GetVarName}" + + $" CondPubVar={stmt.ConditionPubVar} True={stmt.TrueBlockName} False={stmt.FalseBlockName}" + + $" LoopBodyEndReturnTo={stmt.LoopBodyEndReturnTo}{dup}{fp}"); + } + sb.AppendLine(); + } + return sb.ToString(); + } } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintRenderDataService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintRenderDataService.cs new file mode 100644 index 00000000..33cf1c81 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintRenderDataService.cs @@ -0,0 +1,68 @@ +using System.Collections.Generic; +using System.Linq; +using KitX.Core.Contract.Workflow; +using Serilog; + +namespace KitX.Core.Workflow.Blueprint; + +/// +/// Classifies blueprint connections into Exec (execution flow) and Data (data flow) +/// by inspecting the source pin type of each connection. +/// +public class BlueprintRenderDataService : IBlueprintRenderDataService +{ + /// + public BlueprintRenderData GetRenderData(Contract.Workflow.Blueprint blueprint) + { + var exec = new List(); + var data = new List(); + + Log.Debug("[RenderData] === Classifying {ConnCount} connections from blueprint '{BpName}' ===", + blueprint.Connections.Count, blueprint.Name); + + foreach (var conn in blueprint.Connections) + { + var sourceNode = blueprint.GetNodeById(conn.SourceNodeId); + var targetNode = blueprint.GetNodeById(conn.TargetNodeId); + var sourcePin = sourceNode?.OutputPins + .FirstOrDefault(p => p.Id == conn.SourcePinId); + var targetPin = targetNode?.InputPins + .FirstOrDefault(p => p.Id == conn.TargetPinId); + + var srcPinType = sourcePin?.Type.ToString() ?? "null"; + var tgtPinType = targetPin?.Type.ToString() ?? "null"; + + // [DIAG] Log both classification results for comparison + var classifyBySourceOnly = sourcePin?.Type == PinType.Execution; + var classifyByEither = sourcePin?.Type == PinType.Execution + || targetPin?.Type == PinType.Execution; + + if (classifyBySourceOnly) + { + exec.Add(conn); + Log.Debug("[RenderData] EXEC {SrcName}.{SrcPin} [{SrcType}] -> {TgtName}.{TgtPin} [{TgtType}] (bySource={BySrc}, byEither={ByEither})", + sourceNode?.Name ?? "NULL", sourcePin?.Name ?? "?", + targetNode?.Name ?? "NULL", targetPin?.Name ?? "?", + srcPinType, tgtPinType, classifyBySourceOnly, classifyByEither); + } + else + { + data.Add(conn); + Log.Debug("[RenderData] DATA {SrcName}.{SrcPin} [{SrcType}] -> {TgtName}.{TgtPin} [{TgtType}] (bySource={BySrc}, byEither={ByEither})", + sourceNode?.Name ?? "NULL", sourcePin?.Name ?? "?", + targetNode?.Name ?? "NULL", targetPin?.Name ?? "?", + srcPinType, tgtPinType, classifyBySourceOnly, classifyByEither); + } + } + + Log.Debug("[RenderData] === Result: {ExecCount} exec, {DataCount} data (total {Total}) ===", + exec.Count, data.Count, exec.Count + data.Count); + + return new BlueprintRenderData + { + AllNodes = blueprint.Nodes.ToList(), + ExecConnections = exec, + DataConnections = data + }; + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintService.cs index e8c01d59..8c0dfb80 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintService.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintService.cs @@ -14,31 +14,21 @@ public class BlueprintService : IBlueprintService { private readonly IBlockScriptParser _parser; private readonly IBlockScriptExecutor _executor; - private readonly IFlowProcessingService _flowProcessingService; - private readonly IConnectionCreationService _connectionCreationService; + private readonly INodeCreationService _nodeFactory; private readonly ILayoutService _layoutService; - /// - /// Creates a new BlueprintService instance - /// public BlueprintService( IBlockScriptParser parser, IBlockScriptExecutor executor, - IFlowProcessingService flowProcessingService, - IConnectionCreationService connectionCreationService, + INodeCreationService nodeFactory, ILayoutService layoutService) { _parser = parser; _executor = executor; - _flowProcessingService = flowProcessingService; - _connectionCreationService = connectionCreationService; + _nodeFactory = nodeFactory; _layoutService = layoutService; } - /// - /// Creates a new empty blueprint - /// - /// New blueprint public Contract.Workflow.Blueprint CreateBlueprint() { Log.Information("Creating new Blueprint"); @@ -50,12 +40,6 @@ public Contract.Workflow.Blueprint CreateBlueprint() }; } - /// - /// Imports blueprint from BlockScript source code - /// - /// BlockScript source code - /// Helper functions available - /// Imported blueprint, or null if conversion failed public Contract.Workflow.Blueprint? ImportFromBlockScript(string sourceCode, List? helperFunctions = null) { try @@ -63,10 +47,7 @@ public Contract.Workflow.Blueprint CreateBlueprint() Log.Information("Importing Blueprint from BlockScript"); var converter = new BlockScriptToBlueprintConverter( - _parser, - _flowProcessingService, - _connectionCreationService, - _layoutService); + _parser, _nodeFactory, _layoutService); var blueprint = converter.Convert(sourceCode, helperFunctions); blueprint.ModifiedAt = DateTime.Now; @@ -79,17 +60,11 @@ public Contract.Workflow.Blueprint CreateBlueprint() } } - /// - /// Exports blueprint to BlockScript source code - /// - /// Blueprint to export - /// BlockScript source code public string ExportToBlockScript(Contract.Workflow.Blueprint blueprint) { try { Log.Information("Exporting Blueprint to BlockScript"); - var converter = new BlueprintToBlockScriptConverter(); return converter.Convert(blueprint); } @@ -100,27 +75,14 @@ public string ExportToBlockScript(Contract.Workflow.Blueprint blueprint) } } - /// - /// Executes blueprint by converting to BlockScript and running - /// - /// Blueprint to execute - /// Execution result public async Task ExecuteBlueprintAsync(Contract.Workflow.Blueprint blueprint) { try { Log.Information("Executing Blueprint"); - - // Convert blueprint to BlockScript var converter = new BlueprintToBlockScriptConverter(); var blockScript = converter.ConvertToBlockScript(blueprint); - - // Execute the BlockScript - var result = await _executor.ExecuteAsync( - blockScript, - null, - CancellationToken.None); - + var result = await _executor.ExecuteAsync(blockScript, null, CancellationToken.None); return result; } catch (Exception ex) diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ConnectionCreationService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ConnectionCreationService.cs index 7daad8e9..a0409255 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ConnectionCreationService.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ConnectionCreationService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Threading; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/FlowProcessingService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/FlowProcessingService.cs index dc232bee..948736f0 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/FlowProcessingService.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/FlowProcessingService.cs @@ -255,13 +255,14 @@ public void ProcessMainBlock(BlockDefinition block, ConversionContext context, I if (context.PendingNodesForExecChain.Count > 0) { - var parentNodeIndex = nodes.Count - 1; - foreach (var pendingNode in context.PendingNodesForExecChain) + var reversed = Enumerable.Reverse(context.PendingNodesForExecChain).ToList(); + var insertIndex = nodes.Count - 1; + foreach (var pendingNode in reversed) { if (!nodes.Any(n => n.Id == pendingNode.Id)) { - nodes.Insert(parentNodeIndex, pendingNode); - parentNodeIndex++; + nodes.Insert(insertIndex, pendingNode); + insertIndex++; } } context.PendingNodesForExecChain.Clear(); diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/LayoutService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/LayoutService.cs index 072fc14c..2fbbb6b8 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/LayoutService.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/LayoutService.cs @@ -1,106 +1,380 @@ +using System; using System.Collections.Generic; using System.Linq; using KitX.Core.Contract.Workflow; +using Serilog; namespace KitX.Core.Workflow.Blueprint; +/// +/// Recursive subgraph layout engine. +/// Builds a tree of LayoutRegions (Linear / Fork) from the exec chain, +/// then measures and arranges each region with vertical branch separation +/// and smart line wrapping. +/// public class LayoutService : ILayoutService { + // Layout constants + private const double HSpacing = 220; + private const double VSpacing = 130; + private const double ForkVGap = 200; + private const double ForkHGap = 40; + private const double MaxRowWidth = 1200; + private const double XOffset = 50; + private const double YOffset = 50; + private const double NodeWidth = 200; + private const double NodeHeight = 100; + + /// public void LayoutNodes(Contract.Workflow.Blueprint blueprint) { - var depths = new Dictionary(); - var visited = new HashSet(); + if (blueprint.Nodes.Count == 0) return; + + Log.Debug("[Layout] === LayoutNodes called: {NodeCount} nodes, {ConnCount} connections ===", + blueprint.Nodes.Count, blueprint.Connections.Count); + + // Phase 1: Build exec adjacency map + var execMap = BuildExecAdjacencyMap(blueprint); - // Phase 1: Calculate depths along exec chain + // Phase 2: Find entry node var entry = blueprint.Nodes.FirstOrDefault(n => n.NodeType == BlueprintNodeType.Entry); - if (entry != null) + if (entry == null) + { + Log.Warning("[Layout] No Entry node found, skipping layout"); + return; + } + + // Phase 3: Build region tree starting from entry + var visited = new HashSet(); + var placed = new HashSet(); + var rootRegion = BuildRegionTree(entry.Id, execMap, blueprint, visited, placed); + + // Phase 4: Measure and arrange + if (rootRegion != null) { - depths[entry.Id] = 0; - CalculateExecDepths(blueprint, entry, depths, visited); + rootRegion.Measure(); + rootRegion.Arrange(XOffset, YOffset, blueprint); } - // Phase 2: Place data nodes (Const etc.) at their consumer's depth + // Phase 5: Place data nodes near their consumers + PlaceDataNodes(blueprint, placed); + + // Phase 6: Log final positions foreach (var node in blueprint.Nodes) { - if (depths.ContainsKey(node.Id)) continue; + Log.Debug("[Layout] {Name,-35} ({Type,-6}) at ({X:F0}, {Y:F0})", + node.Name, node.NodeType, node.X, node.Y); + } + Log.Debug("[Layout] === Layout complete: {Placed} nodes positioned ===", placed.Count); + } - var firstConn = blueprint.Connections - .FirstOrDefault(c => c.SourceNodeId == node.Id); - if (firstConn != null && depths.ContainsKey(firstConn.TargetNodeId)) - { - depths[node.Id] = depths[firstConn.TargetNodeId]; - } + /// + /// Builds nodeId → [(pinName, targetNodeId)] mapping for exec-type connections only + /// + private Dictionary> BuildExecAdjacencyMap( + Contract.Workflow.Blueprint blueprint) + { + var map = new Dictionary>(); + + foreach (var conn in blueprint.Connections) + { + var sourceNode = blueprint.GetNodeById(conn.SourceNodeId); + if (sourceNode == null) continue; + + var sourcePin = sourceNode.OutputPins.FirstOrDefault(p => p.Id == conn.SourcePinId); + if (sourcePin == null || sourcePin.Type != PinType.Execution) continue; + + if (!map.ContainsKey(conn.SourceNodeId)) + map[conn.SourceNodeId] = []; + map[conn.SourceNodeId].Add((sourcePin.Name, conn.TargetNodeId)); } - // Phase 3: Remaining unplaced nodes appended after max depth - var maxDepth = depths.Values.Count > 0 ? depths.Values.Max() : 0; - foreach (var node in blueprint.Nodes) + Log.Debug("[Layout] Exec adjacency map: {Count} nodes with exec outputs", map.Count); + return map; + } + + /// + /// Recursively builds a tree of LayoutRegions from the exec chain. + /// + private LayoutRegion? BuildRegionTree( + string nodeId, + Dictionary> execMap, + Contract.Workflow.Blueprint blueprint, + HashSet visited, + HashSet placed) + { + if (visited.Contains(nodeId)) return null; + visited.Add(nodeId); + + if (!execMap.TryGetValue(nodeId, out var targets) || targets.Count == 0) { - if (!depths.ContainsKey(node.Id)) + // Leaf node — single linear region + placed.Add(nodeId); + return new LinearRegion(nodeId); + } + + if (targets.Count == 1) + { + // Single exec output — linear chain + var childId = targets[0].TargetId; + var linear = new LinearRegion(); + linear.NodeIds.Add(nodeId); + placed.Add(nodeId); + + // Append child's chain if not yet visited + if (!visited.Contains(childId)) { - depths[node.Id] = ++maxDepth; + var childRegion = BuildRegionTree(childId, execMap, blueprint, visited, placed); + if (childRegion is LinearRegion childLinear) + { + linear.NodeIds.AddRange(childLinear.NodeIds); + // Preserve the child's Child (e.g., a ForkRegion) when merging + if (childLinear.Child != null) + linear.Child = childLinear.Child; + } + else if (childRegion != null) + { + linear.Child = childRegion; + } } + + return linear; } - // Phase 4: Group by depth + assign coordinates - var depthGroups = new Dictionary>(); - foreach (var node in blueprint.Nodes) + if (targets.Count == 2) { - var depth = depths[node.Id]; - if (!depthGroups.ContainsKey(depth)) - depthGroups[depth] = new List(); - depthGroups[depth].Add(node); + // Fork: Branch (True/False) or Loop (LoopBody/LoopEnd) + placed.Add(nodeId); + + var node = blueprint.GetNodeById(nodeId); + Log.Debug("[Layout] Fork node: {Name} ({Type}) → [{Pin1}, {Pin2}]", + node?.Name, node?.NodeType, targets[0].PinName, targets[1].PinName); + + // First target → UpperBranch (True / LoopBody) + var upper = BuildRegionTree(targets[0].TargetId, execMap, blueprint, visited, placed); + + // Second target → LowerBranch (False / LoopEnd) + var lower = BuildRegionTree(targets[1].TargetId, execMap, blueprint, visited, placed); + + return new ForkRegion(nodeId, upper, lower); } - const double HSpacing = 220; - const double VSpacing = 130; - const double XOffset = 50; - const double YOffset = 50; + // Fallback: 3+ exec outputs (treat as linear) + placed.Add(nodeId); + return new LinearRegion(nodeId); + } + + /// + /// Places data-only nodes (Const, etc.) near their first consumer + /// + private void PlaceDataNodes(Contract.Workflow.Blueprint blueprint, HashSet placed) + { + var dataNodes = blueprint.Nodes.Where(n => !placed.Contains(n.Id)).ToList(); + if (dataNodes.Count == 0) return; + + Log.Debug("[Layout] Placing {Count} data nodes (Const, etc.)", dataNodes.Count); - foreach (var group in depthGroups.OrderBy(g => g.Key)) + // Group data nodes by their first consumer target + var dataNodeIndex = 0; + foreach (var node in dataNodes) { - var depth = group.Key; - var sorted = group.Value - .OrderBy(n => n.NodeType == BlueprintNodeType.Const ? 1 : 0) - .ToList(); - for (int i = 0; i < sorted.Count; i++) + var firstConn = blueprint.Connections + .FirstOrDefault(c => c.SourceNodeId == node.Id); + if (firstConn != null) { - sorted[i].X = XOffset + depth * HSpacing; - sorted[i].Y = YOffset + i * VSpacing; + var targetNode = blueprint.GetNodeById(firstConn.TargetNodeId); + if (targetNode != null) + { + // Place above-left of the consumer + node.X = targetNode.X - HSpacing; + node.Y = targetNode.Y - (dataNodeIndex + 1) * (NodeHeight * 0.6); + placed.Add(node.Id); + dataNodeIndex++; + continue; + } } + + // No consumer found — place at bottom + var maxY = blueprint.Nodes.Where(n => placed.Contains(n.Id)) + .Select(n => n.Y + n.Height).DefaultIfEmpty(0).Max(); + node.X = XOffset; + node.Y = maxY + VSpacing; + placed.Add(node.Id); } } + #region Layout Region Types + /// - /// Only traverse Execution-type connections for depth calculation, - /// so data-dependency edges don't pull nodes into wrong layers. + /// Abstract base for a measurable, arrangeable layout region /// - private void CalculateExecDepths(Contract.Workflow.Blueprint blueprint, BlueprintNode node, - Dictionary depths, HashSet visited) + private abstract class LayoutRegion { - if (visited.Contains(node.Id)) return; - visited.Add(node.Id); + public double MeasuredWidth { get; protected set; } + public double MeasuredHeight { get; protected set; } + public abstract void Measure(double availableWidth = MaxRowWidth); + public abstract void Arrange(double x, double y, Contract.Workflow.Blueprint bp); + } + + /// + /// Linear chain of nodes, left-to-right, with smart wrapping at availableWidth + /// + private class LinearRegion : LayoutRegion + { + public List NodeIds { get; } = []; + public LayoutRegion? Child; + + private List> _rows = []; + + public LinearRegion() { } + + public LinearRegion(string singleNodeId) + { + NodeIds.Add(singleNodeId); + } + + public override void Measure(double availableWidth = MaxRowWidth) + { + _rows.Clear(); + if (NodeIds.Count == 0 && Child == null) + { + MeasuredWidth = 0; + MeasuredHeight = 0; + return; + } - // Find output pins that are Exec type (or named "Exec" / "True" / "False" / "LoopBody" / "LoopEnd") - var execOutPinIds = node.OutputPins - .Where(p => p.Type == PinType.Execution) - .Select(p => p.Id) - .ToHashSet(); + // Build rows with wrapping relative to available width + var currentRow = new List(); + double rowWidth = 0; - var execConnections = blueprint.Connections.Where(c => - c.SourceNodeId == node.Id && execOutPinIds.Contains(c.SourcePinId)); + foreach (var nodeId in NodeIds) + { + var nodeWidth = rowWidth == 0 ? NodeWidth : HSpacing + NodeWidth; + if (rowWidth + nodeWidth > availableWidth && currentRow.Count > 0) + { + _rows.Add(currentRow); + currentRow = []; + rowWidth = 0; + nodeWidth = NodeWidth; + } + currentRow.Add(nodeId); + rowWidth += nodeWidth; + } + + if (currentRow.Count > 0) + _rows.Add(currentRow); + + // Calculate dimensions + MeasuredWidth = _rows.Count > 0 + ? _rows.Max(r => r.Count * NodeWidth + Math.Max(0, r.Count - 1) * (HSpacing - NodeWidth)) + : 0; + MeasuredHeight = _rows.Count > 0 + ? _rows.Count * NodeHeight + Math.Max(0, _rows.Count - 1) * (VSpacing - NodeHeight) + : 0; + + // Include child region + if (Child != null) + { + Child.Measure(availableWidth); + MeasuredWidth = Math.Max(MeasuredWidth, Child.MeasuredWidth); + MeasuredHeight += Child.MeasuredHeight > 0 ? VSpacing + Child.MeasuredHeight : 0; + } + } - foreach (var conn in execConnections) + public override void Arrange(double x, double y, Contract.Workflow.Blueprint bp) { - if (blueprint.GetNodeById(conn.TargetNodeId) is { } targetNode) + double currentY = y; + + foreach (var row in _rows) { - var currentDepth = depths[node.Id]; - if (!depths.ContainsKey(targetNode.Id) || depths[targetNode.Id] < currentDepth + 1) + double currentX = x; + foreach (var nodeId in row) { - depths[targetNode.Id] = currentDepth + 1; + var node = bp.GetNodeById(nodeId); + if (node != null) + { + node.X = currentX; + node.Y = currentY; + } + currentX += HSpacing; } - CalculateExecDepths(blueprint, targetNode, depths, visited); + currentY += VSpacing; + } + + // Arrange child region + if (Child != null) + { + double childX = x; + double childY = _rows.Count > 0 ? currentY : y; + Child.Arrange(childX, childY, bp); } } } + + /// + /// Fork region: a fork node (Branch/Loop) with upper and lower sub-branches + /// arranged vertically, indented to the right. + /// + private class ForkRegion : LayoutRegion + { + public string ForkNodeId; + public LayoutRegion? UpperBranch; + public LayoutRegion? LowerBranch; + + public ForkRegion(string forkNodeId, LayoutRegion? upper, LayoutRegion? lower) + { + ForkNodeId = forkNodeId; + UpperBranch = upper; + LowerBranch = lower; + } + + public override void Measure(double availableWidth = MaxRowWidth) + { + // Branches are indented to the right of the fork node + double branchAvailableWidth = Math.Max(NodeWidth, availableWidth - NodeWidth - ForkHGap); + + UpperBranch?.Measure(branchAvailableWidth); + LowerBranch?.Measure(branchAvailableWidth); + + double upperW = UpperBranch?.MeasuredWidth ?? 0; + double lowerW = LowerBranch?.MeasuredWidth ?? 0; + double upperH = UpperBranch?.MeasuredHeight ?? 0; + double lowerH = LowerBranch?.MeasuredHeight ?? 0; + + // Width: fork node + gap + max of branches (includes indentation) + MeasuredWidth = NodeWidth + ForkHGap + Math.Max(upperW, lowerW); + // Height: fork node + gap + both branches stacked vertically + MeasuredHeight = NodeHeight + ForkVGap + upperH + (lowerH > 0 ? VSpacing + lowerH : 0); + } + + public override void Arrange(double x, double y, Contract.Workflow.Blueprint bp) + { + // Place fork node + var forkNode = bp.GetNodeById(ForkNodeId); + if (forkNode != null) + { + forkNode.X = x; + forkNode.Y = y; + } + + // Indent branches to the right of the fork node + double branchX = x + NodeWidth + ForkHGap; + double upperY = y + NodeHeight + ForkVGap; + + // Arrange upper branch (True / LoopBody) + if (UpperBranch != null) + { + UpperBranch.Arrange(branchX, upperY, bp); + } + + // Arrange lower branch below upper branch (False / LoopEnd) + if (LowerBranch != null) + { + double lowerY = upperY + (UpperBranch?.MeasuredHeight ?? 0) + VSpacing; + LowerBranch.Arrange(branchX, lowerY, bp); + } + } + } + + #endregion } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/DataEdgeBuilder.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/DataEdgeBuilder.cs new file mode 100644 index 00000000..abf27b87 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/DataEdgeBuilder.cs @@ -0,0 +1,259 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using KitX.Core.Contract.Workflow; +using Serilog; + +namespace KitX.Core.Workflow.Blueprint.Pipeline; + +/// +/// Phase 4+5: Creates data edges from FormattedBlockScript argument analysis. +/// Handles PubVar references, ConstBlock connections, DefaultValues, and data edge dedup. +/// +public class DataEdgeBuilder +{ + public void Build(PipelineContext context) + { + // Process all statements in all blocks + foreach (var block in context.FormattedScript.Blocks) + { + foreach (var stmt in block.Statements) + { + ProcessStatement(stmt, context); + } + } + + // Deduplicate data edges (Phase 5) + DeduplicateDataEdges(context); + + Log.Debug("[DataEdgeBuilder] Done: {DataEdgeCount} data edges", + context.DataEdges.Count); + } + + // ────────────────────────────────────────────── + // Statement processing + // ────────────────────────────────────────────── + + private void ProcessStatement(FormattedStatement stmt, PipelineContext context) + { + switch (stmt.Kind) + { + case FormattedStatementKind.Assignment: + case FormattedStatementKind.Expression: + ProcessCallArguments(stmt, context); + break; + + case FormattedStatementKind.Print: + ProcessSingleValueInput(stmt, stmt.Arguments, "Value", context); + break; + + case FormattedStatementKind.Set: + ProcessSetValue(stmt, context); + break; + + case FormattedStatementKind.Pause: + ProcessSingleValueInput(stmt, stmt.Arguments, "Milliseconds", context); + break; + + case FormattedStatementKind.Branch: + ProcessConditionInput(stmt, context); + break; + + case FormattedStatementKind.Loop: + ProcessConditionInput(stmt, context); + break; + } + } + + // ────────────────────────────────────────────── + // Call/Assignment argument processing + // ────────────────────────────────────────────── + + private void ProcessCallArguments(FormattedStatement stmt, PipelineContext context) + { + if (!context.NodeByStatementId.TryGetValue(stmt.StatementId, out var targetNode)) return; + if (stmt.Arguments == null) return; + + for (int i = 0; i < stmt.Arguments.Count; i++) + { + var arg = stmt.Arguments[i]; + var pinName = GetParamPinName(targetNode, i); + if (pinName == null) continue; + + ProcessArgument(arg, targetNode, pinName, stmt, i, context); + } + } + + /// + /// Processes a single argument expression and creates a data edge or sets DefaultValue. + /// + private void ProcessArgument(string arg, BlueprintNode targetNode, string targetPinName, + FormattedStatement parentStmt, int argIndex, PipelineContext context) + { + var trimmed = arg.Trim(); + + // String literal → DefaultValue + if (trimmed.StartsWith("\"") && trimmed.EndsWith("\"")) + { + var value = trimmed[1..^1]; + SetDefaultValue(targetNode, targetPinName, value); + return; + } + + // Numeric literal → DefaultValue + if (int.TryParse(trimmed, out _) || double.TryParse(trimmed, out _)) + { + SetDefaultValue(targetNode, targetPinName, trimmed); + return; + } + + // PubVar reference → find PubVarAssignment source + if (ExprUtils.IsPubVarName(trimmed)) + { + ConnectPubVarSource(trimmed, targetNode, targetPinName, context); + return; + } + + // ConstBlock variable → data edge from ConstNode.Value + if (context.ConstNodes.TryGetValue(trimmed, out var constNode)) + { + context.DataEdges.Add(new PendingDataEdge + { + SourceNodeId = constNode.Id, + SourcePinName = "Value", + TargetNodeId = targetNode.Id, + TargetPinName = targetPinName + }); + return; + } + + // Unknown identifier → try as variable reference (set DefaultValue as fallback) + Log.Warning("[DataEdgeBuilder] Unresolved argument: {Arg} in stmt {StmtId}", trimmed, parentStmt.StatementId); + SetDefaultValue(targetNode, targetPinName, trimmed); + } + + // ────────────────────────────────────────────── + // Special node argument processing + // ────────────────────────────────────────────── + + private void ProcessSingleValueInput(FormattedStatement stmt, List? args, + string pinName, PipelineContext context) + { + if (args == null || args.Count == 0) return; + if (!context.NodeByStatementId.TryGetValue(stmt.StatementId, out var targetNode)) return; + + ProcessArgument(args[0], targetNode, pinName, stmt, 0, context); + } + + private void ProcessSetValue(FormattedStatement stmt, PipelineContext context) + { + if (!context.NodeByStatementId.TryGetValue(stmt.StatementId, out var targetNode)) return; + if (stmt.Arguments == null || stmt.Arguments.Count == 0) return; + + // Set's Value pin receives the second argument (first was extracted as SetVarName) + if (stmt.Arguments.Count > 0) + { + ProcessArgument(stmt.Arguments[0], targetNode, "Value", stmt, 0, context); + } + } + + private void ProcessConditionInput(FormattedStatement stmt, PipelineContext context) + { + if (string.IsNullOrEmpty(stmt.ConditionPubVar)) return; + if (!context.NodeByStatementId.TryGetValue(stmt.StatementId, out var targetNode)) return; + + // Condition PubVar → find the PubVarAssignment source and connect to Condition pin + ConnectPubVarSource(stmt.ConditionPubVar, targetNode, "Condition", context); + } + + // ────────────────────────────────────────────── + // PubVar source connection + // ────────────────────────────────────────────── + + private void ConnectPubVarSource(string pubVarName, BlueprintNode targetNode, + string targetPinName, PipelineContext context) + { + // Find the PubVarAssignment that produces this PubVar + foreach (var kvp in context.PubVarAssignments) + { + if (kvp.Value.PubVarName == pubVarName) + { + context.DataEdges.Add(new PendingDataEdge + { + SourceNodeId = kvp.Value.SourceNode.Id, + SourcePinName = kvp.Value.SourcePin.Name, + TargetNodeId = targetNode.Id, + TargetPinName = targetPinName, + PubVarName = pubVarName + }); + return; + } + } + + // If not found in PubVarAssignments, check if it's a standalone Get assignment + // (e.g., vaaa0002 = Get("currentLoop")) + foreach (var kvp in context.PubVarAssignments) + { + if (kvp.Value.PubVarName == pubVarName) + { + context.DataEdges.Add(new PendingDataEdge + { + SourceNodeId = kvp.Value.SourceNode.Id, + SourcePinName = kvp.Value.SourcePin.Name, + TargetNodeId = targetNode.Id, + TargetPinName = targetPinName, + PubVarName = pubVarName + }); + return; + } + } + + Log.Warning("[DataEdgeBuilder] PubVar source not found: {PubVar}", pubVarName); + } + + // ────────────────────────────────────────────── + // Data edge deduplication (Phase 5) + // ────────────────────────────────────────────── + + private void DeduplicateDataEdges(PipelineContext context) + { + var unique = new Dictionary(); + + foreach (var edge in context.DataEdges) + { + // Key by (source node, source pin, target node, target pin, pubvar) + var key = $"{edge.SourceNodeId}|{edge.SourcePinName}|{edge.TargetNodeId}|{edge.TargetPinName}|{edge.PubVarName ?? ""}"; + if (!unique.ContainsKey(key)) + unique[key] = edge; + } + + var beforeCount = context.DataEdges.Count; + context.DataEdges = unique.Values.ToList(); + + if (beforeCount != context.DataEdges.Count) + { + Log.Debug("[DataEdgeBuilder] Dedup: {Before} → {After} data edges", beforeCount, context.DataEdges.Count); + } + } + + // ────────────────────────────────────────────── + // Helpers + // ────────────────────────────────────────────── + + private static void SetDefaultValue(BlueprintNode node, string pinName, string value) + { + var pin = node.InputPins.FirstOrDefault(p => p.Name == pinName); + if (pin != null) + pin.DefaultValue = value; + } + + private static string? GetParamPinName(BlueprintNode node, int argIndex) + { + // Find parameter pins (skip Exec pin at index 0) + var paramPins = node.InputPins.Where(p => p.Name != "Exec").ToList(); + if (argIndex < paramPins.Count) + return paramPins[argIndex].Name; + return null; + } + +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ExprUtils.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ExprUtils.cs new file mode 100644 index 00000000..1d7c05cb --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ExprUtils.cs @@ -0,0 +1,162 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using KitX.Core.Contract.Workflow; + +namespace KitX.Core.Workflow.Blueprint.Pipeline; + +/// +/// Shared expression-parsing utilities used across pipeline phases. +/// +public static class ExprUtils +{ + public static readonly HashSet BuiltinFunctions = new() + { + "Get", "Set", "Print", "Pause", "Branch", "Loop", "LoopBodyEnd", "Break" + }; + + public static readonly HashSet NonExtractableFunctions = new() + { + "Set", "Print", "Pause" + }; + + public static readonly HashSet FlowControlFunctions = new() + { + "Branch", "Loop", "LoopBodyEnd", "Break" + }; + + /// Parses an expression string using Roslyn. Returns null on failure. + public static ExpressionSyntax? ParseExpression(string expression) + { + try + { + var wrappedCode = "_ = " + expression + ";"; + var syntaxTree = CSharpSyntaxTree.ParseText(wrappedCode, cancellationToken: CancellationToken.None); + var root = syntaxTree.GetCompilationUnitRoot(); + var globalStmt = root.Members.FirstOrDefault() as GlobalStatementSyntax; + var stmt = globalStmt?.Statement as ExpressionStatementSyntax; + if (stmt?.Expression is AssignmentExpressionSyntax assignment) + return assignment.Right; + return stmt?.Expression; + } + catch { return null; } + } + + /// Parses a full statement (may be assignment or plain expression). + public static (ExpressionSyntax? rightExpr, string? assignedVar, bool isPubVar)? ParseStatement(string statement) + { + try + { + var wrappedCode = "_ = " + statement + ";"; + var syntaxTree = CSharpSyntaxTree.ParseText(wrappedCode, cancellationToken: CancellationToken.None); + var root = syntaxTree.GetCompilationUnitRoot(); + var globalStmt = root.Members.FirstOrDefault() as GlobalStatementSyntax; + var stmt = globalStmt?.Statement as ExpressionStatementSyntax; + if (stmt?.Expression == null) return (null, null, false); + + if (stmt.Expression is AssignmentExpressionSyntax outerAssignment) + { + ExpressionSyntax rightExpr = outerAssignment.Right; + AssignmentExpressionSyntax? innermost = null; + while (rightExpr is AssignmentExpressionSyntax nested) + { + innermost = nested; + rightExpr = nested.Right; + } + var assignedVar = (innermost ?? outerAssignment).Left.ToString().Trim(); + return (rightExpr, assignedVar, IsPubVarName(assignedVar)); + } + return (stmt.Expression, null, false); + } + catch { return (null, null, false); } + } + + /// Extracts method name from an invocation expression. + public static string GetMethodName(InvocationExpressionSyntax invoke) + { + if (invoke.Expression is IdentifierNameSyntax id) return id.Identifier.Text; + if (invoke.Expression is GenericNameSyntax generic) return generic.Identifier.Text; + if (invoke.Expression is MemberAccessExpressionSyntax member) return member.Name.Identifier.Text; + return string.Empty; + } + + /// Checks if a name follows the PubVar convention (vaaaNNNN). + public static bool IsPubVarName(string name) + => !string.IsNullOrEmpty(name) && name.StartsWith("vaaa") && name.Length >= 8; + + /// Checks if a token is a simple variable reference. + public static bool IsVariableReference(string token) + { + if (string.IsNullOrWhiteSpace(token)) return false; + if (bool.TryParse(token, out _)) return false; + if (int.TryParse(token, out _) || double.TryParse(token, out _)) return false; + if (token.StartsWith("\"")) return false; + if (token.Contains("(")) return false; + return Regex.IsMatch(token, @"^[a-zA-Z_]\w*$"); + } + + /// Generates a PubVar name like vaaa0001. + public static string GeneratePubVarName(int counter) => $"vaaa{counter:D4}"; + + /// + /// Finds all nested invocation arguments in an invocation (non-builtin only). + /// Returns them in depth-first order (deepest first). + /// + public static List FindNestedInvocations(InvocationExpressionSyntax invoke) + { + var result = new List(); + foreach (var arg in invoke.ArgumentList.Arguments) + CollectNested(arg.Expression, result); + return result; + } + + private static void CollectNested(ExpressionSyntax expr, List result) + { + if (expr is InvocationExpressionSyntax invoke) + { + var funcName = GetMethodName(invoke); + // Recurse into arguments first (depth-first) + foreach (var arg in invoke.ArgumentList.Arguments) + CollectNested(arg.Expression, result); + // Only extract non-builtin, non-flow-control functions + if (!NonExtractableFunctions.Contains(funcName) && !FlowControlFunctions.Contains(funcName)) + result.Add(invoke); + } + else if (expr is ParenthesizedExpressionSyntax paren) + CollectNested(paren.Expression, result); + } + + /// Computes a fingerprint string for a call expression for reuse detection. + public static string ComputeFingerprint(string funcName, List args) + => $"{funcName}({string.Join(",", args.Select(a => a.Trim().Replace(" ", "")))})"; + + /// Gets the string value of a string literal, or null. + public static string? GetStringLiteralValue(ExpressionSyntax expr) + { + if (expr is LiteralExpressionSyntax lit && lit.Token.IsKind(SyntaxKind.StringLiteralToken)) + return lit.Token.ValueText; + return null; + } + + /// Gets the integer value of a literal, or null. + public static int? GetIntLiteralValue(ExpressionSyntax expr) + { + if (expr is LiteralExpressionSyntax lit && lit.Token.IsKind(SyntaxKind.NumericLiteralToken) + && lit.Token.Value is int val) + return val; + return null; + } + + /// Checks if an expression is a string literal. + public static bool IsStringLiteral(ExpressionSyntax expr) + => expr is LiteralExpressionSyntax lit && lit.Token.IsKind(SyntaxKind.StringLiteralToken); + + /// Checks if an expression is a numeric literal. + public static bool IsNumericLiteral(ExpressionSyntax expr) + => expr is LiteralExpressionSyntax lit && lit.Token.IsKind(SyntaxKind.NumericLiteralToken); +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/FormattedBlockScript.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/FormattedBlockScript.cs new file mode 100644 index 00000000..f1c039fd --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/FormattedBlockScript.cs @@ -0,0 +1,140 @@ +using System; +using System.Collections.Generic; + +namespace KitX.Core.Workflow.Blueprint.Pipeline; + +/// +/// The formatted block script produced by Phase 2 (ScriptFormatter). +/// All nested calls have been expanded; each statement is a simple, flat form. +/// +public class FormattedBlockScript +{ + /// + /// Ordered list of blocks, starting with MainBlock. + /// + public List Blocks { get; set; } = new(); + + /// + /// Main block name (always first in Blocks list). + /// + public string MainBlockName { get; set; } = "MainBlock"; +} + +/// +/// A formatted block with flat statements. +/// +public class FormattedBlock +{ + public string Name { get; set; } = string.Empty; + public List Statements { get; set; } = new(); + public string? NextBlockName { get; set; } +} + +/// +/// A single flattened statement in the formatted script. +/// No nesting in expressions — all calls are either flat or assigned to PubVars. +/// +public class FormattedStatement +{ + public string StatementId { get; set; } = Guid.NewGuid().ToString(); + public string BlockName { get; set; } = string.Empty; + public string OriginalExpression { get; set; } = string.Empty; + public int SourceLine { get; set; } + + /// + /// What kind of statement this is. + /// + public FormattedStatementKind Kind { get; set; } + + // --- For Assignment / Get --- + /// + /// The PubVar being assigned (e.g. "vaaa0001"), if this is an assignment. + /// + public string? PubVarTarget { get; set; } + + // --- For function calls --- + /// + /// Function name (e.g. "HelperFuncCompare", "Get", "Set", "Print"). + /// + public string? FunctionName { get; set; } + + /// + /// Raw argument strings after expansion (no nested calls). + /// Each argument is either a literal, a PubVar name, a ConstBlock variable name, or Get("varName"). + /// + public List Arguments { get; set; } = new(); + + // --- For flow control --- + public string? ConditionExpression { get; set; } + public string? ConditionPubVar { get; set; } + public string? TrueBlockName { get; set; } + public string? FalseBlockName { get; set; } + public string? LoopBodyEndReturnTo { get; set; } + + // --- Metadata --- + /// + /// True if this statement was inserted by the ScriptFormatter as a Loop condition duplication + /// before a LoopBodyEnd statement. + /// + public bool IsLoopConditionDuplication { get; set; } + + // --- For Set --- + /// + /// The variable name being set (for Set statements). + /// + public string? SetVarName { get; set; } + + // --- For Get --- + /// + /// The variable name being read (for Get statements). + /// + public string? GetVarName { get; set; } + + // --- Expression fingerprint for reuse detection --- + /// + /// Computed fingerprint for reuse detection. Same expression → same fingerprint → reuse nodes. + /// + public string? Fingerprint { get; set; } +} + +/// +/// Kinds of formatted statements. +/// +public enum FormattedStatementKind +{ + /// Unknown or unclassified + Unknown, + + /// Print(expr) + Print, + + /// Pause(ms) + Pause, + + /// Set("varName", expr) + Set, + + /// Get("varName") — standalone or as part of a PubVar assignment + Get, + + /// pubVar = FunctionCall(args...) + Assignment, + + /// Branch(condition, trueBlock, falseBlock) + Branch, + + /// Loop(condition, loopBody, afterLoop) + Loop, + + /// LoopBodyEnd("parentBlock") + LoopBodyEnd, + + /// Break() + Break, + + /// NextBlock = ... (handled internally, no node created) + NextBlockAssignment, + + /// Plain expression without assignment + Expression, +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/NodeBuilder.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/NodeBuilder.cs new file mode 100644 index 00000000..52855549 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/NodeBuilder.cs @@ -0,0 +1,411 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using KitX.Core.Contract.Workflow; +using Serilog; + +namespace KitX.Core.Workflow.Blueprint.Pipeline; + +/// +/// Phase 3: Creates all Blueprint nodes and exec flow edges from FormattedBlockScript. +/// Implements PubVar reuse detection during node creation (§6.5). +/// +public class NodeBuilder +{ + private readonly INodeCreationService _factory; + private readonly List _helpers; + private readonly HashSet _helperNames; + + // Deferred cross-block edge definitions (resolved after all blocks processed) + private readonly List<(string stmtId, string? trueBlock, string? falseBlock)> _branchDefs = new(); + private readonly List<(string stmtId, string? loopBody, string? loopEnd, string parentBlock)> _loopDefs = new(); + private readonly List<(string stmtId, string returnToBlock, string blockName, string? prevStmtId)> _loopBodyEndDefs = new(); + private readonly Dictionary _blockNextBlock = new(); + private readonly Dictionary _blockLastStmtId = new(); + private readonly Dictionary _blockEndsWithFlowCtrl = new(); + + public NodeBuilder(INodeCreationService factory, List helpers) + { + _factory = factory; + _helpers = helpers; + _helperNames = new HashSet(helpers.Select(h => h.Name)); + } + + public void Build(FormattedBlockScript script, PipelineContext context) + { + // Create EntryNode + var entry = _factory.CreateEntryNode(0, 0); + context.EntryNode = entry; + context.AllNodes.Add(entry); + context.NodeByStatementId["__entry__"] = entry; + + // Process all blocks in order (MainBlock first) + foreach (var block in script.Blocks) + ProcessBlock(block, context); + + // Resolve cross-block edges + ResolveCrossBlockEdges(context); + + Log.Debug("[NodeBuilder] Done: {NodeCount} nodes, {ExecEdgeCount} exec edges", + context.AllNodes.Count, context.ExecEdges.Count); + } + + // ────────────────────────────────────────────── + // Block processing + // ────────────────────────────────────────────── + + private void ProcessBlock(FormattedBlock block, PipelineContext context) + { + string? prevStmtId = null; + BlueprintNode? prevNode = null; + BlueprintNode? firstNode = null; + bool endsWithFlowCtrl = false; + + // MainBlock chains from Entry + if (block.Name == context.FormattedScript.MainBlockName) + { + prevStmtId = "__entry__"; + prevNode = context.EntryNode; + } + + foreach (var stmt in block.Statements) + { + var node = ProcessStatement(stmt, block.Name, context, ref prevNode, ref prevStmtId); + if (firstNode == null && node != null) + firstNode = node; + + endsWithFlowCtrl = stmt.Kind is FormattedStatementKind.Branch + or FormattedStatementKind.Loop + or FormattedStatementKind.LoopBodyEnd + or FormattedStatementKind.Break; + } + + if (firstNode != null) + { + context.BlockFirstNodes[block.Name] = firstNode; + _blockLastStmtId[block.Name] = prevStmtId ?? ""; + } + + if (!string.IsNullOrEmpty(block.NextBlockName)) + _blockNextBlock[block.Name] = block.NextBlockName; + + _blockEndsWithFlowCtrl[block.Name] = endsWithFlowCtrl; + } + + // ────────────────────────────────────────────── + // Statement dispatch + // ────────────────────────────────────────────── + + private BlueprintNode? ProcessStatement(FormattedStatement stmt, string blockName, + PipelineContext context, ref BlueprintNode? prevNode, ref string? prevStmtId) + { + switch (stmt.Kind) + { + case FormattedStatementKind.Assignment: + case FormattedStatementKind.Expression: + return ProcessCallOrAssignment(stmt, context, ref prevNode, ref prevStmtId); + + case FormattedStatementKind.Print: + return ChainNewNode(_factory.CreatePrintNode(), stmt, context, ref prevNode, ref prevStmtId); + + case FormattedStatementKind.Set: + return ChainNewNode(_factory.CreateSetNode(stmt.SetVarName ?? ""), stmt, context, ref prevNode, ref prevStmtId); + + case FormattedStatementKind.Get: + return ChainNewNode(_factory.CreateGetNode(stmt.GetVarName ?? ""), stmt, context, ref prevNode, ref prevStmtId); + + case FormattedStatementKind.Pause: + return ChainNewNode(_factory.CreatePauseNode(), stmt, context, ref prevNode, ref prevStmtId); + + case FormattedStatementKind.Branch: + { + var node = ChainNewNode(_factory.CreateBranchNode(), stmt, context, ref prevNode, ref prevStmtId); + _branchDefs.Add((stmt.StatementId, stmt.TrueBlockName, stmt.FalseBlockName)); + return node; + } + + case FormattedStatementKind.Loop: + { + var node = ChainNewNode(_factory.CreateLoopNode(), stmt, context, ref prevNode, ref prevStmtId); + _loopDefs.Add((stmt.StatementId, stmt.TrueBlockName, stmt.FalseBlockName, blockName)); + context.LoopNodesByParent[blockName] = (LoopNode)node!; + return node; + } + + case FormattedStatementKind.LoopBodyEnd: + // No node created. Record for deferred resolution. + _loopBodyEndDefs.Add((stmt.StatementId, stmt.LoopBodyEndReturnTo ?? "", blockName, prevStmtId)); + return null; + + case FormattedStatementKind.Break: + return ChainNewNode(_factory.CreateBreakNode(), stmt, context, ref prevNode, ref prevStmtId); + + default: + return null; + } + } + + // ────────────────────────────────────────────── + // Call / Assignment node creation (with PubVar reuse) + // ────────────────────────────────────────────── + + private BlueprintNode? ProcessCallOrAssignment(FormattedStatement stmt, + PipelineContext context, ref BlueprintNode? prevNode, ref string? prevStmtId) + { + // --- PubVar reuse check (§6.5) --- + // For Get statements: only LOOP_COND_DUP reuses by PubVarTarget; others always create new + // For non-Get statements: reuse by fingerprint + PubVarAssignment? existing = null; + + if (stmt.FunctionName == "Get") + { + // Get nodes: only reuse for LOOP_COND_DUP with matching PubVarTarget + if (stmt.IsLoopConditionDuplication && !string.IsNullOrEmpty(stmt.PubVarTarget)) + { + existing = context.PubVarAssignments.Values + .FirstOrDefault(p => p.PubVarName == stmt.PubVarTarget + && p.SourceNode.NodeType == BlueprintNodeType.Get); + } + // Non-DUP Get: never reuse → existing stays null + } + else if (!string.IsNullOrEmpty(stmt.Fingerprint) + && context.PubVarAssignments.TryGetValue(stmt.Fingerprint, out var fpExisting)) + { + existing = fpExisting; + } + + if (existing != null) + { + context.NodeByStatementId[stmt.StatementId] = existing.SourceNode; + + // Chain to the shared main node + AddExecEdge(prevStmtId, stmt.StatementId, context); + prevNode = existing.SourceNode; + prevStmtId = stmt.StatementId; + + Log.Debug("[NodeBuilder] Reused node: {Key}", stmt.Fingerprint ?? stmt.PubVarTarget); + return existing.SourceNode; + } + + // --- Create new node --- + BlueprintNode mainNode; + if (stmt.FunctionName == "Get") + { + // Get assignment → create GetNode + var varName = stmt.Arguments?.Count > 0 ? stmt.Arguments[0].Trim('"') : ""; + mainNode = _factory.CreateGetNode(varName); + } + else + { + var isHelper = _helperNames.Contains(stmt.FunctionName); + mainNode = isHelper + ? _factory.CreateCallHelperNode(stmt.FunctionName!) + : _factory.CreateCallNode(stmt.FunctionName!); + + // Add parameter pins based on helper definition or argument count + AddParamPins(mainNode, stmt.FunctionName!, stmt.Arguments?.Count ?? 0); + } + + context.AllNodes.Add(mainNode); + context.NodeByStatementId[stmt.StatementId] = mainNode; + + AddExecEdge(prevStmtId, stmt.StatementId, context); + prevNode = mainNode; + prevStmtId = stmt.StatementId; + + // Register PubVar assignment for reuse + if (!string.IsNullOrEmpty(stmt.PubVarTarget)) + { + var outputPinName = stmt.FunctionName == "Get" ? "Value" : "Return"; + var outputPin = mainNode.OutputPins.First(p => p.Name == outputPinName); + // Get: key by PubVarTarget (each Get is unique, identified by its PubVar) + // Others: key by fingerprint (for reuse detection) + var key = stmt.FunctionName == "Get" ? stmt.PubVarTarget : (stmt.Fingerprint ?? stmt.PubVarTarget); + context.PubVarAssignments[key] = new PubVarAssignment + { + PubVarName = stmt.PubVarTarget, + SourceNode = mainNode, + SourcePin = outputPin, + StatementId = stmt.StatementId, + }; + } + + return mainNode; + } + + // ────────────────────────────────────────────── + // Cross-block edge resolution + // ────────────────────────────────────────────── + + private void ResolveCrossBlockEdges(PipelineContext context) + { + // Branch: True → trueBlock first, False → falseBlock first + foreach (var (stmtId, trueBlock, falseBlock) in _branchDefs) + { + if (!string.IsNullOrEmpty(trueBlock) && + context.BlockFirstNodes.TryGetValue(trueBlock, out var trueFirst)) + { + var targetStmtId = FindStmtIdForNode(trueFirst, context); + context.ExecEdges.Add(new PendingExecEdge + { + SourceStatementId = stmtId, + TargetStatementId = targetStmtId, + SourcePinName = "True", + TargetPinName = "Exec", + IsSpecialRouting = true + }); + } + + if (!string.IsNullOrEmpty(falseBlock) && + context.BlockFirstNodes.TryGetValue(falseBlock, out var falseFirst)) + { + var targetStmtId = FindStmtIdForNode(falseFirst, context); + context.ExecEdges.Add(new PendingExecEdge + { + SourceStatementId = stmtId, + TargetStatementId = targetStmtId, + SourcePinName = "False", + TargetPinName = "Exec", + IsSpecialRouting = true + }); + } + } + + // Loop: LoopBody → body first, LoopEnd → end first + foreach (var (stmtId, loopBody, loopEnd, _) in _loopDefs) + { + if (!string.IsNullOrEmpty(loopBody) && + context.BlockFirstNodes.TryGetValue(loopBody, out var bodyFirst)) + { + var targetStmtId = FindStmtIdForNode(bodyFirst, context); + context.ExecEdges.Add(new PendingExecEdge + { + SourceStatementId = stmtId, + TargetStatementId = targetStmtId, + SourcePinName = "LoopBody", + TargetPinName = "Exec", + IsSpecialRouting = true + }); + } + + if (!string.IsNullOrEmpty(loopEnd) && + context.BlockFirstNodes.TryGetValue(loopEnd, out var endFirst)) + { + var targetStmtId = FindStmtIdForNode(endFirst, context); + context.ExecEdges.Add(new PendingExecEdge + { + SourceStatementId = stmtId, + TargetStatementId = targetStmtId, + SourcePinName = "LoopEnd", + TargetPinName = "Exec", + IsSpecialRouting = true + }); + } + } + + // LoopBodyEnd: prev node → Loop.Exec (of parent block) + foreach (var (_, returnToBlock, _, prevStmtId) in _loopBodyEndDefs) + { + if (!context.LoopNodesByParent.TryGetValue(returnToBlock, out var loopNode)) continue; + var loopStmtId = FindStmtIdForNode(loopNode, context); + if (prevStmtId == null || loopStmtId == null) continue; + + context.ExecEdges.Add(new PendingExecEdge + { + SourceStatementId = prevStmtId, + TargetStatementId = loopStmtId, + SourcePinName = "Exec", + TargetPinName = "Exec" + }); + } + + // Sequential fall-through for blocks without flow control endings + foreach (var (blockName, nextBlockName) in _blockNextBlock) + { + if (_blockEndsWithFlowCtrl.GetValueOrDefault(blockName)) continue; + if (string.IsNullOrEmpty(nextBlockName)) continue; + if (!context.BlockFirstNodes.TryGetValue(nextBlockName, out var nextFirst)) continue; + if (!_blockLastStmtId.TryGetValue(blockName, out var lastStmtId)) continue; + + var targetStmtId = FindStmtIdForNode(nextFirst, context); + if (targetStmtId == null) continue; + + context.ExecEdges.Add(new PendingExecEdge + { + SourceStatementId = lastStmtId, + TargetStatementId = targetStmtId, + SourcePinName = "Exec", + TargetPinName = "Exec" + }); + } + } + + // ────────────────────────────────────────────── + // Helpers + // ────────────────────────────────────────────── + + /// Creates a node, registers it, and chains it into the exec flow. + private BlueprintNode ChainNewNode(BlueprintNode node, FormattedStatement stmt, + PipelineContext context, ref BlueprintNode? prevNode, ref string? prevStmtId) + { + context.AllNodes.Add(node); + context.NodeByStatementId[stmt.StatementId] = node; + + AddExecEdge(prevStmtId, stmt.StatementId, context); + + prevNode = node; + prevStmtId = stmt.StatementId; + return node; + } + + private void AddExecEdge(string? sourceStmtId, string targetStmtId, PipelineContext context) + { + if (sourceStmtId == null) return; + context.ExecEdges.Add(new PendingExecEdge + { + SourceStatementId = sourceStmtId, + TargetStatementId = targetStmtId + }); + } + + /// Finds the statement ID that maps to a given node. + private string? FindStmtIdForNode(BlueprintNode node, PipelineContext context) + { + foreach (var kvp in context.NodeByStatementId) + { + if (kvp.Value.Id == node.Id) + return kvp.Key; + } + return null; + } + + /// Adds parameter input pins to a Call/CallHelper node. + private void AddParamPins(BlueprintNode node, string funcName, int argCount) + { + var helper = _helpers.FirstOrDefault(h => h.Name == funcName); + if (helper != null) + { + for (int i = 0; i < helper.Parameters.Count; i++) + { + node.InputPins.Add(new BlueprintPin + { + Name = helper.Parameters[i].Name, + Direction = PinDirection.Input, + Type = PinType.Any + }); + } + } + else + { + for (int i = 0; i < argCount; i++) + { + node.InputPins.Add(new BlueprintPin + { + Name = $"param{i + 1}", + Direction = PinDirection.Input, + Type = PinType.Any + }); + } + } + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineAssembler.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineAssembler.cs new file mode 100644 index 00000000..601f85f4 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineAssembler.cs @@ -0,0 +1,156 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using KitX.Core.Contract.Workflow; +using Serilog; + +namespace KitX.Core.Workflow.Blueprint.Pipeline; + +/// +/// Phase 6: Assembles the final Blueprint from all pipeline outputs. +/// Converts PendingExecEdges and PendingDataEdges into BlueprintConnections. +/// +public class PipelineAssembler +{ + private PipelineContext _ctx = null!; + private Dictionary _nodeById = new(); + + public Contract.Workflow.Blueprint Assemble(PipelineContext context) + { + _ctx = context; + + var bp = new Contract.Workflow.Blueprint + { + Name = "Imported from BlockScript", + PubVarNames = new List(context.PubVarNames), + HelperFunctions = context.HelperFunctions, + }; + + // Add ConstValues + foreach (var kvp in context.ConstNodes) + { + bp.ConstValues.Add(new VariableConstant + { + Name = kvp.Value.ConstName, + DefaultValue = kvp.Value.ConstValue, + Type = kvp.Value.ConstType ?? "string" + }); + } + + // Add all nodes + build lookup + foreach (var node in context.AllNodes) + { + bp.AddNode(node); + _nodeById[node.Id] = node; + } + + // Convert exec edges to connections + foreach (var edge in context.ExecEdges) + { + var conn = CreateExecConnection(edge); + if (conn != null) + bp.AddConnection(conn); + } + + // Convert data edges to connections + foreach (var edge in context.DataEdges) + { + var conn = CreateDataConnection(edge); + if (conn != null) + bp.AddConnection(conn); + } + + Log.Debug("[PipelineAssembler] Assembled: {NodeCount} nodes, {ConnCount} connections", + bp.Nodes.Count, bp.Connections.Count); + + return bp; + } + + // ────────────────────────────────────────────── + // Exec edge → BlueprintConnection + // ────────────────────────────────────────────── + + private BlueprintConnection? CreateExecConnection(PendingExecEdge edge) + { + var sourceNode = ResolveNodeByStmtId(edge.SourceStatementId); + var targetNode = ResolveNodeByStmtId(edge.TargetStatementId); + + if (sourceNode == null || targetNode == null) + { + Log.Warning("[Assembler] Exec edge node not resolved: {Src} → {Tgt}", + edge.SourceStatementId, edge.TargetStatementId); + return null; + } + + var sourcePin = FindOutputPin(sourceNode, edge.SourcePinName); + var targetPin = FindInputPin(targetNode, edge.TargetPinName); + + if (sourcePin == null || targetPin == null) + { + Log.Warning("[Assembler] Pin not found: {Src}.{SrcPin} → {Tgt}.{TgtPin}", + sourceNode.Name, edge.SourcePinName, targetNode.Name, edge.TargetPinName); + return null; + } + + return new BlueprintConnection + { + SourceNodeId = sourceNode.Id, + SourcePinId = sourcePin.Id, + TargetNodeId = targetNode.Id, + TargetPinId = targetPin.Id + }; + } + + // ────────────────────────────────────────────── + // Data edge → BlueprintConnection + // ────────────────────────────────────────────── + + private BlueprintConnection? CreateDataConnection(PendingDataEdge edge) + { + if (!_nodeById.TryGetValue(edge.SourceNodeId, out var sourceNode) || + !_nodeById.TryGetValue(edge.TargetNodeId, out var targetNode)) + { + Log.Warning("[Assembler] Data edge node not found: {Src} → {Tgt}", + edge.SourceNodeId, edge.TargetNodeId); + return null; + } + + var sourcePin = FindOutputPin(sourceNode, edge.SourcePinName); + var targetPin = FindInputPin(targetNode, edge.TargetPinName); + + if (sourcePin == null || targetPin == null) + { + Log.Warning("[Assembler] Data pin not found: {Src}.{SPin} → {Tgt}.{TPin}", + sourceNode.Name, edge.SourcePinName, targetNode.Name, edge.TargetPinName); + return null; + } + + return new BlueprintConnection + { + SourceNodeId = sourceNode.Id, + SourcePinId = sourcePin.Id, + TargetNodeId = targetNode.Id, + TargetPinId = targetPin.Id, + PubVarName = edge.PubVarName + }; + } + + // ────────────────────────────────────────────── + // Helpers + // ────────────────────────────────────────────── + + private BlueprintNode? ResolveNodeByStmtId(string stmtId) + { + if (stmtId == "__entry__") + return _ctx.EntryNode; + + _ctx.NodeByStatementId.TryGetValue(stmtId, out var node); + return node; + } + + private static BlueprintPin? FindOutputPin(BlueprintNode node, string pinName) + => node.OutputPins.FirstOrDefault(p => p.Name == pinName); + + private static BlueprintPin? FindInputPin(BlueprintNode node, string pinName) + => node.InputPins.FirstOrDefault(p => p.Name == pinName); +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineContext.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineContext.cs new file mode 100644 index 00000000..f7b7efb8 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineContext.cs @@ -0,0 +1,138 @@ +using System; +using System.Collections.Generic; +using KitX.Core.Contract.Workflow; + +namespace KitX.Core.Workflow.Blueprint.Pipeline; + +/// +/// Accumulated state flowing through all 6 conversion phases. +/// Replaces the old ConversionContext. +/// +public class PipelineContext +{ + // --- Input --- + public required BlockScript Script { get; set; } + public List HelperFunctions { get; set; } = []; + + // --- Phase 1 output --- + public Dictionary ConstNodes { get; set; } = new(); + public List PubVarNames { get; set; } = new(); + + // --- Phase 2 output --- + public FormattedBlockScript FormattedScript { get; set; } = new(); + + // --- Phase 3 output --- + public List AllNodes { get; set; } = new(); + public EntryNode? EntryNode { get; set; } + public Dictionary NodeByStatementId { get; set; } = new(); + public Dictionary BlockFirstNodes { get; set; } = new(); + public List ExecEdges { get; set; } = new(); + + // --- Phase 4 output --- + public List DataEdges { get; set; } = new(); + + // --- Shared state --- + public int NextPubVarCounter { get; set; } = 0; + public HashSet VisitedBlocks { get; set; } = new(); + + /// + /// PubVar reuse tracking: fingerprint → assignment info. + /// When the same expression is assigned to the same PubVar in multiple blocks, + /// the nodes are shared (not re-created). + /// + public Dictionary PubVarAssignments { get; set; } = new(); + + /// + /// parentBlockName → LoopNode (for LoopBodyEnd resolution) + /// + public Dictionary LoopNodesByParent { get; set; } = new(); + + /// + /// parentBlockName → condition PubVar assignment info (for Loop condition duplication) + /// + public Dictionary LoopConditions { get; set; } = new(); + + // --- Debug tracking --- + public List DebugLog { get; set; } = new(); +} + +/// +/// Tracks a PubVar assignment for reuse detection. +/// Key = expression fingerprint (e.g. "HelperFuncCompare(BLE,Get(currentLoop),loopMax)"). +/// +public class PubVarAssignment +{ + public string PubVarName { get; set; } = string.Empty; + public BlueprintNode SourceNode { get; set; } = null!; + public BlueprintPin SourcePin { get; set; } = null!; + public string StatementId { get; set; } = string.Empty; + + /// + /// Sub-assignments that are part of this expression (e.g. Get nodes that feed into a CallHelper). + /// Key = sub-expression fingerprint, Value = (node, pin, statementId). + /// + public Dictionary SubAssignments { get; set; } = new(); +} + +/// +/// Sub-assignment within a PubVar expression chain. +/// +public class SubAssignment +{ + public BlueprintNode Node { get; set; } = null!; + public BlueprintPin Pin { get; set; } = null!; + public string StatementId { get; set; } = string.Empty; +} + +/// +/// Information about a Loop's condition for duplication before LoopBodyEnd. +/// +public class ConditionInfo +{ + /// + /// The PubVar that holds the condition result (e.g. "vaaa0001"). + /// + public string ConditionPubVar { get; set; } = string.Empty; + + /// + /// The expanded condition expression (e.g. "HelperFuncCompare(\"BLE\", Get(\"currentLoop\"), loopMax)"). + /// + public string RawExpression { get; set; } = string.Empty; + + /// + /// The fingerprint of the condition expression for reuse detection. + /// + public string Fingerprint { get; set; } = string.Empty; + + /// + /// The formatted statements that evaluate the condition (may be multiple if nested). + /// The last one assigns to ConditionPubVar. + /// + public List ExpansionStatements { get; set; } = new(); +} + +/// +/// A pending exec edge to be created between two nodes. +/// +public class PendingExecEdge +{ + public string SourceStatementId { get; set; } = string.Empty; + public string TargetStatementId { get; set; } = string.Empty; + public string SourcePinName { get; set; } = "Exec"; + public string TargetPinName { get; set; } = "Exec"; + + // For special routing (Branch.True, Branch.False, Loop.LoopBody, Loop.LoopEnd) + public bool IsSpecialRouting { get; set; } +} + +/// +/// A pending data edge to be created between two pins. +/// +public class PendingDataEdge +{ + public string SourceNodeId { get; set; } = string.Empty; + public string SourcePinName { get; set; } = string.Empty; + public string TargetNodeId { get; set; } = string.Empty; + public string TargetPinName { get; set; } = string.Empty; + public string? PubVarName { get; set; } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ScriptFormatter.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ScriptFormatter.cs new file mode 100644 index 00000000..1f24c1b1 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ScriptFormatter.cs @@ -0,0 +1,532 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using KitX.Core.Contract.Workflow; +using Serilog; + +namespace KitX.Core.Workflow.Blueprint.Pipeline; + +/// +/// Phase 2: Takes a parsed BlockScript AST and produces a FormattedBlockScript +/// where all nested function calls have been expanded into PubVar assignments. +/// Also duplicates Loop condition evaluations before LoopBodyEnd statements. +/// +public class ScriptFormatter +{ + private readonly List _helperFunctions; + + public ScriptFormatter(List helperFunctions) + { + _helperFunctions = helperFunctions; + } + + public FormattedBlockScript Format(BlockScript script, PipelineContext context) + { + var result = new FormattedBlockScript(); + + // Initialize counter: find max existing PubVar number to avoid conflicts + context.NextPubVarCounter = 1; + foreach (var name in context.PubVarNames) + { + if (name.StartsWith("vaaa") && name.Length >= 8 && int.TryParse(name[4..], out var num)) + context.NextPubVarCounter = Math.Max(context.NextPubVarCounter, num + 1); + } + + // Format MainBlock + if (script.MainBlock != null) + { + var mainBlock = FormatBlock(script.MainBlock, context); + result.Blocks.Add(mainBlock); + result.MainBlockName = mainBlock.Name; + } + + // Format NamedBlocks + foreach (var kvp in script.NamedBlocks) + { + if (!result.Blocks.Any(b => b.Name == kvp.Value.Name)) + { + result.Blocks.Add(FormatBlock(kvp.Value, context)); + } + } + + // Format LoopBlocks + foreach (var kvp in script.LoopBlocks) + { + if (!result.Blocks.Any(b => b.Name == kvp.Value.Name)) + { + result.Blocks.Add(FormatBlock(kvp.Value, context)); + } + } + + // Insert Loop condition duplications before LoopBodyEnd + InsertLoopConditionDuplications(result, context); + + Log.Debug("[ScriptFormatter] Done: {BlockCount} blocks, {StmtCount} statements", + result.Blocks.Count, result.Blocks.Sum(b => b.Statements.Count)); + + return result; + } + + // ────────────────────────────────────────────── + // Block-level formatting + // ────────────────────────────────────────────── + + private FormattedBlock FormatBlock(BlockDefinition blockDef, PipelineContext context) + { + var result = new FormattedBlock { Name = blockDef.Name, NextBlockName = blockDef.NextBlockName }; + foreach (var stmt in blockDef.Statements) + { + var formatted = FormatStatement(stmt, blockDef.Name, context); + result.Statements.AddRange(formatted); + } + return result; + } + + /// Returns a list of FormattedStatements (may be multiple when expansion occurs). + private List FormatStatement(BlockStatement stmt, string blockName, PipelineContext context) + { + switch (stmt) + { + case FlowControlStatement flowCtrl: + return FormatFlowControl(flowCtrl, blockName, context); + case ExpressionStatement expr: + return FormatExpressionStatement(expr, blockName, context); + default: + return new(); + } + } + + // ────────────────────────────────────────────── + // Flow control formatting + // ────────────────────────────────────────────── + + private List FormatFlowControl(FlowControlStatement flowCtrl, string blockName, PipelineContext context) + { + var result = new List(); + + switch (flowCtrl.ControlType) + { + case FlowControlType.Branch: + { + var (condStmts, condPubVar) = ExpandCondition(flowCtrl.ConditionExpression, blockName, context); + result.AddRange(condStmts); + result.Add(new FormattedStatement + { + BlockName = blockName, + Kind = FormattedStatementKind.Branch, + ConditionPubVar = condPubVar, + ConditionExpression = flowCtrl.ConditionExpression, + TrueBlockName = flowCtrl.TrueBlockName, + FalseBlockName = flowCtrl.FalseBlockName, + OriginalExpression = flowCtrl.SourceCode, + SourceLine = flowCtrl.LineNumber + }); + } + break; + + case FlowControlType.Loop: + { + var (condStmts, condPubVar) = ExpandCondition(flowCtrl.ConditionExpression, blockName, context); + result.AddRange(condStmts); + + var loopStmt = new FormattedStatement + { + BlockName = blockName, + Kind = FormattedStatementKind.Loop, + ConditionPubVar = condPubVar, + ConditionExpression = flowCtrl.ConditionExpression, + TrueBlockName = flowCtrl.TrueBlockName, + FalseBlockName = flowCtrl.FalseBlockName, + OriginalExpression = flowCtrl.SourceCode, + SourceLine = flowCtrl.LineNumber + }; + result.Add(loopStmt); + + // Store condition for duplication before LoopBodyEnd + if (!string.IsNullOrEmpty(condPubVar)) + { + context.LoopConditions[blockName] = new ConditionInfo + { + ConditionPubVar = condPubVar, + RawExpression = flowCtrl.ConditionExpression, + ExpansionStatements = condStmts.ToList() + }; + } + } + break; + + case FlowControlType.LoopBodyEnd: + result.Add(new FormattedStatement + { + BlockName = blockName, + Kind = FormattedStatementKind.LoopBodyEnd, + LoopBodyEndReturnTo = flowCtrl.LoopBodyEndReturnTo, + OriginalExpression = flowCtrl.SourceCode, + SourceLine = flowCtrl.LineNumber + }); + break; + + case FlowControlType.Break: + result.Add(new FormattedStatement + { + BlockName = blockName, + Kind = FormattedStatementKind.Break, + OriginalExpression = flowCtrl.SourceCode, + SourceLine = flowCtrl.LineNumber + }); + break; + } + return result; + } + + // ────────────────────────────────────────────── + // Expression statement formatting + // ────────────────────────────────────────────── + + private List FormatExpressionStatement(ExpressionStatement exprStmt, string blockName, PipelineContext context) + { + var result = new List(); + var expression = exprStmt.Expression; + + // Try to parse the expression + var parsed = ExprUtils.ParseStatement(expression); + if (parsed == null) return result; + + var (rightExpr, assignedVar, isPubVar) = parsed.Value; + + if (rightExpr == null) return result; + + // Handle "NextBlock = ..." assignments (should be handled as FlowControl by parser) + if (assignedVar != null && assignedVar == "NextBlock") + return result; + + // If RHS is a function invocation, process it + if (rightExpr is InvocationExpressionSyntax invoke) + { + var funcName = ExprUtils.GetMethodName(invoke); + if (string.IsNullOrEmpty(funcName)) return result; + + // Skip flow control functions (handled by FlowControlStatement) + if (ExprUtils.FlowControlFunctions.Contains(funcName)) return result; + + result.AddRange(FormatInvocation(invoke, funcName, blockName, context, assignedVar)); + return result; + } + + // If RHS is just an identifier (variable reference), skip + return result; + } + + /// + /// Formats a function invocation, expanding nested calls in arguments. + /// + private List FormatInvocation( + InvocationExpressionSyntax invoke, string funcName, string blockName, + PipelineContext context, string? assignedVar) + { + var result = new List(); + + // Expand nested calls in arguments first + var (expansionStmts, currentArgExprs) = ExpandArguments(invoke, blockName, context); + result.AddRange(expansionStmts); + + // Determine statement kind and extract info + FormattedStatementKind kind; + string? pubVarTarget = null; + string? setVarName = null; + string? getVarName = null; + + switch (funcName) + { + case "Print": + kind = FormattedStatementKind.Print; + break; + case "Pause": + kind = FormattedStatementKind.Pause; + break; + case "Set": + kind = FormattedStatementKind.Set; + // First arg is varName (string literal) + if (currentArgExprs.Count > 0) + { + var firstArgExpr = invoke.ArgumentList.Arguments[0].Expression; + var varNameLiteral = ExprUtils.GetStringLiteralValue(firstArgExpr); + setVarName = varNameLiteral ?? currentArgExprs[0]; + currentArgExprs.RemoveAt(0); + } + break; + case "Get": + kind = FormattedStatementKind.Assignment; + if (currentArgExprs.Count > 0) + { + var firstArgExpr = invoke.ArgumentList.Arguments[0].Expression; + getVarName = ExprUtils.GetStringLiteralValue(firstArgExpr) ?? currentArgExprs[0]; + } + // Auto-generate PubVar if not already assigned + if (string.IsNullOrEmpty(assignedVar) || !ExprUtils.IsPubVarName(assignedVar)) + { + pubVarTarget = ExprUtils.GeneratePubVarName(context.NextPubVarCounter++); + if (!context.PubVarNames.Contains(pubVarTarget)) + context.PubVarNames.Add(pubVarTarget); + } + else + { + pubVarTarget = assignedVar; + } + break; + default: + // Helper or regular function call + if (!string.IsNullOrEmpty(assignedVar) && assignedVar != "_") + { + kind = FormattedStatementKind.Assignment; + pubVarTarget = ExprUtils.IsPubVarName(assignedVar) ? assignedVar : null; + // Register as PubVar if it follows the convention + if (ExprUtils.IsPubVarName(assignedVar) && !context.PubVarNames.Contains(assignedVar)) + context.PubVarNames.Add(assignedVar); + } + else + { + kind = FormattedStatementKind.Expression; + } + break; + } + + var fingerprint = kind is FormattedStatementKind.Assignment or FormattedStatementKind.Expression + ? ExprUtils.ComputeFingerprint(funcName, currentArgExprs) + : null; + + result.Add(new FormattedStatement + { + BlockName = blockName, + Kind = kind, + FunctionName = funcName, + PubVarTarget = pubVarTarget, + SetVarName = setVarName, + GetVarName = getVarName, + Arguments = currentArgExprs, + OriginalExpression = invoke.ToString(), + SourceLine = 0, + Fingerprint = fingerprint + }); + + return result; + } + + // ────────────────────────────────────────────── + // Argument expansion (nested call extraction) + // ────────────────────────────────────────────── + + /// + /// Expands nested function calls in arguments. + /// Returns (expansionStatements, currentArgStrings). + /// + private (List stmts, List argExprs) ExpandArguments( + InvocationExpressionSyntax invoke, string blockName, PipelineContext context) + { + var stmts = new List(); + var argExprs = new List(); + + foreach (var arg in invoke.ArgumentList.Arguments) + { + var (expanded, finalExpr) = ExpandExpression(arg.Expression, blockName, context); + stmts.AddRange(expanded); + argExprs.Add(finalExpr); + } + + return (stmts, argExprs); + } + + /// + /// Recursively expands nested calls within a single expression. + /// Returns (expansionStatements, finalExpressionString). + /// + private (List stmts, string finalExpr) ExpandExpression( + ExpressionSyntax expr, string blockName, PipelineContext context) + { + // Literal → return as-is + if (expr is LiteralExpressionSyntax) + return (new(), expr.ToString()); + + // Simple identifier → return as-is + if (expr is IdentifierNameSyntax) + return (new(), expr.ToString()); + + // Invocation → may need expansion + if (expr is InvocationExpressionSyntax invoke) + { + var funcName = ExprUtils.GetMethodName(invoke); + + // Built-in functions (Get/Set/Print/Pause) stay inline + if (ExprUtils.NonExtractableFunctions.Contains(funcName)) + return (new(), invoke.ToString()); + + // Flow control functions → should not appear as arguments + if (ExprUtils.FlowControlFunctions.Contains(funcName)) + return (new(), invoke.ToString()); + + // This is a helper/regular function call that needs extraction + // First, recursively expand ITS arguments + var allStmts = new List(); + var currentArgs = new List(); + foreach (var arg in invoke.ArgumentList.Arguments) + { + var (expanded, finalExpr) = ExpandExpression(arg.Expression, blockName, context); + allStmts.AddRange(expanded); + currentArgs.Add(finalExpr); + } + + // Generate PubVar for this call + var pubVarName = ExprUtils.GeneratePubVarName(context.NextPubVarCounter++); + if (!context.PubVarNames.Contains(pubVarName)) + context.PubVarNames.Add(pubVarName); + + var fingerprint = ExprUtils.ComputeFingerprint(funcName, currentArgs); + + allStmts.Add(new FormattedStatement + { + BlockName = blockName, + Kind = FormattedStatementKind.Assignment, + PubVarTarget = pubVarName, + FunctionName = funcName, + Arguments = currentArgs, + OriginalExpression = $"{pubVarName} = {invoke}", + Fingerprint = fingerprint + }); + + return (allStmts, pubVarName); + } + + // Parenthesized expression + if (expr is ParenthesizedExpressionSyntax paren) + return ExpandExpression(paren.Expression, blockName, context); + + // Default: return as-is + return (new(), expr.ToString()); + } + + // ────────────────────────────────────────────── + // Condition expansion (for Branch/Loop) + // ────────────────────────────────────────────── + + private (List stmts, string? pubVar) ExpandCondition( + string conditionExpression, string blockName, PipelineContext context) + { + var result = new List(); + if (string.IsNullOrWhiteSpace(conditionExpression)) + return (result, null); + + var trimmed = conditionExpression.Trim(); + + // Already a PubVar reference + if (ExprUtils.IsPubVarName(trimmed)) + return (result, trimmed); + + // ConstBlock variable + if (context.ConstNodes.ContainsKey(trimmed)) + return (result, null); + + // Parse the expression + var expr = ExprUtils.ParseExpression(trimmed); + if (expr == null) + return (result, null); + + // If it's a simple identifier, no expansion needed + if (expr is IdentifierNameSyntax) + return (result, null); + + // If it's an invocation, expand it + if (expr is InvocationExpressionSyntax invoke) + { + var funcName = ExprUtils.GetMethodName(invoke); + if (string.IsNullOrEmpty(funcName)) return (result, null); + + var formatted = FormatInvocation(invoke, funcName, blockName, context, null); + + // The last statement should be the main call + // If it already has a PubVarTarget, use it + var lastStmt = formatted.LastOrDefault(); + if (lastStmt?.PubVarTarget != null) + { + result.AddRange(formatted); + return (result, lastStmt.PubVarTarget); + } + + // If no PubVar was assigned, generate one + var pubVarName = ExprUtils.GeneratePubVarName(context.NextPubVarCounter++); + if (!context.PubVarNames.Contains(pubVarName)) + context.PubVarNames.Add(pubVarName); + if (lastStmt != null) lastStmt.PubVarTarget = pubVarName; + result.AddRange(formatted); + return (result, pubVarName); + } + + return (result, null); + } + + // ────────────────────────────────────────────── + // Loop condition duplication before LoopBodyEnd + // ────────────────────────────────────────────── + + private void InsertLoopConditionDuplications(FormattedBlockScript script, PipelineContext context) + { + foreach (var block in script.Blocks) + { + var insertions = new List<(int index, List stmts)>(); + + for (int i = 0; i < block.Statements.Count; i++) + { + var stmt = block.Statements[i]; + if (stmt.Kind == FormattedStatementKind.LoopBodyEnd + && !string.IsNullOrEmpty(stmt.LoopBodyEndReturnTo)) + { + // Try both the original block name and the Loop sub-block name + // (parser moves Loop stmts into {ParentBlock}_Loop sub-blocks) + var lookupKeys = new[] { stmt.LoopBodyEndReturnTo, $"{stmt.LoopBodyEndReturnTo}_Loop" }; + ConditionInfo? condInfo = null; + foreach (var key in lookupKeys) + { + if (context.LoopConditions.TryGetValue(key, out var ci) && ci.ExpansionStatements.Count > 0) + { + condInfo = ci; + break; + } + } + + if (condInfo != null) + { + var dupStmts = condInfo.ExpansionStatements.Select(CloneStatement).ToList(); + dupStmts.ForEach(s => s.IsLoopConditionDuplication = true); + insertions.Add((i, dupStmts)); + } + } + } + + // Apply in reverse order to preserve indices + foreach (var (index, stmts) in insertions.OrderByDescending(x => x.index)) + block.Statements.InsertRange(index, stmts); + } + } + + private FormattedStatement CloneStatement(FormattedStatement source) => new() + { + StatementId = Guid.NewGuid().ToString(), + BlockName = source.BlockName, + OriginalExpression = source.OriginalExpression, + Kind = source.Kind, + PubVarTarget = source.PubVarTarget, + FunctionName = source.FunctionName, + Arguments = new List(source.Arguments), + ConditionExpression = source.ConditionExpression, + ConditionPubVar = source.ConditionPubVar, + TrueBlockName = source.TrueBlockName, + FalseBlockName = source.FalseBlockName, + LoopBodyEndReturnTo = source.LoopBodyEndReturnTo, + Fingerprint = source.Fingerprint, + SetVarName = source.SetVarName, + GetVarName = source.GetVarName, + IsLoopConditionDuplication = source.IsLoopConditionDuplication, + SourceLine = source.SourceLine + }; +} diff --git a/KitX Clients/KitX Dashboard b/KitX Clients/KitX Dashboard index 3c66964e..eb4619c6 160000 --- a/KitX Clients/KitX Dashboard +++ b/KitX Clients/KitX Dashboard @@ -1 +1 @@ -Subproject commit 3c66964e1ba97104c1736931bb9bc826c755bee0 +Subproject commit eb4619c65bde4112de736aad5aa899ad31e743ae diff --git a/KitX Standard b/KitX Standard index cc7f85e6..10463781 160000 --- a/KitX Standard +++ b/KitX Standard @@ -1 +1 @@ -Subproject commit cc7f85e65b0a234c02e1026a7159dce6fd2635f2 +Subproject commit 104637811039d9755a3b80c22304feaa645d6126 From 1c97b823e47b7884a24e433762cb202106c55b36 Mon Sep 17 00:00:00 2001 From: StarInk Date: Sun, 5 Apr 2026 22:18:59 +0200 Subject: [PATCH 028/110] =?UTF-8?q?=F0=9F=A7=A9=20Refactor(Blueprint):=20?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E4=BB=A3=E7=A0=81=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DI/CoreServiceCollectionExtensions.cs | 6 + .../BlockScriptToBlueprintConverter.cs | 3 - .../BlockScriptToBlueprintConverter.cs.bak | 1714 ----------------- .../Blueprint/BlueprintRenderDataService.cs | 32 +- .../Workflow/Blueprint/BlueprintService.cs | 29 +- .../BlueprintToBlockScriptConverter.cs | 146 +- .../Blueprint/ConnectionCreationService.cs | 399 ---- .../Workflow/Blueprint/ConversionContext.cs | 1 - .../Blueprint/FlowProcessingService.cs | 954 --------- .../Blueprint/IConnectionCreationService.cs | 36 - .../Blueprint/IFlowProcessingService.cs | 34 - KitX Clients/KitX Dashboard | 2 +- KitX Standard | 2 +- 13 files changed, 58 insertions(+), 3300 deletions(-) delete mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlockScriptToBlueprintConverter.cs.bak delete mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ConnectionCreationService.cs delete mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ConversionContext.cs delete mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/FlowProcessingService.cs delete mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/IConnectionCreationService.cs delete mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/IFlowProcessingService.cs diff --git a/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs b/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs index a7af21e4..2aff83f1 100644 --- a/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs +++ b/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs @@ -203,6 +203,12 @@ public static IServiceCollection AddCoreServices(this IServiceCollection service services.AddSingleton(); services.AddSingleton(); + // Blueprint Converters + Log.Information("Registering IBlockScriptToBlueprintConverter..."); + services.AddSingleton(); + Log.Information("Registering IBlueprintToBlockScriptConverter..."); + services.AddSingleton(); + // Blueprint Services Log.Information("Registering IBlueprintService..."); services.AddSingleton(); diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlockScriptToBlueprintConverter.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlockScriptToBlueprintConverter.cs index 8e0b6026..c4eea90f 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlockScriptToBlueprintConverter.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlockScriptToBlueprintConverter.cs @@ -4,9 +4,6 @@ using KitX.Core.Contract.Workflow; using KitX.Core.Workflow.Blueprint.Pipeline; using Serilog; -using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.Blueprint.Pipeline; -using Serilog; namespace KitX.Core.Workflow.Blueprint; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlockScriptToBlueprintConverter.cs.bak b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlockScriptToBlueprintConverter.cs.bak deleted file mode 100644 index 8242b168..00000000 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlockScriptToBlueprintConverter.cs.bak +++ /dev/null @@ -1,1714 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; -using KitX.Core.Contract.Workflow; -using Serilog; - -namespace KitX.Core.Workflow.Blueprint; - -/// -/// Converts BlockScript to Blueprint -/// -public class BlockScriptToBlueprintConverter : IBlockScriptToBlueprintConverter -{ - private readonly IBlockScriptParser _parser; - - public BlockScriptToBlueprintConverter(IBlockScriptParser parser) - { - _parser = parser; - } - - public Contract.Workflow.Blueprint Convert(string sourceCode, List? helperFunctions = null) - { - var result = _parser.Parse(sourceCode); - if (!result.IsSuccess || result.Script == null) - { - throw new InvalidOperationException($"Failed to parse BlockScript: {result.ErrorMessage}"); - } - return Convert(result.Script); - } - - public Contract.Workflow.Blueprint Convert(BlockScript script) - { - var blueprint = new Contract.Workflow.Blueprint - { - Name = "Imported from BlockScript", - HelperFunctions = script.HelperFunctions, - PubVarNames = new List() - }; - - var context = new ConversionContext - { - Blueprint = blueprint, - Script = script, - NodeMap = new Dictionary(), - NextPubVarIndex = 0 - }; - - // Process ConstBlock - if (script.ConstBlock != null) - { - ProcessConstBlock(script.ConstBlock, context); - } - - // Process PubVarBlock - if (script.PubVarBlock != null) - { - ProcessPubVarBlock(script.PubVarBlock, context); - } - - // Process MainBlock - create Entry node and main execution flow - if (script.MainBlock != null) - { - ProcessMainBlock(script.MainBlock, context); - } - - // Process NamedBlocks (for calls from main flow) - foreach (var kvp in script.NamedBlocks) - { - context.NamedBlockMap[kvp.Key] = kvp.Value; - } - - // Process LoopBlocks - foreach (var kvp in script.LoopBlocks) - { - ProcessLoopBlock(kvp.Value, context); - } - - // Auto-layout nodes - LayoutNodes(blueprint); - - Log.Information("[BlueprintConversion] Complete: {NodeCount} nodes, {ConnectionCount} connections", - blueprint.Nodes.Count, blueprint.Connections.Count); - - return blueprint; - } - - private void ProcessConstBlock(BlockDefinition block, ConversionContext context) - { - foreach (var variable in block.Variables) - { - // ✅ 如果 ConstValue 为空,根据类型设置默认值 - var constValue = variable.DefaultValue?.ToString(); - if (string.IsNullOrEmpty(constValue)) - { - constValue = variable.Type.ToLower() switch - { - "int" or "integer" or "long" or "short" or "byte" => "0", - "float" or "double" or "decimal" => "0", - "bool" or "boolean" => "false", - "string" => "", - "char" => "\0", - _ => "" - }; - } - - var constNode = new ConstNode - { - ConstName = variable.Name, - ConstType = variable.Type, - ConstValue = constValue - }; - context.Blueprint.AddNode(constNode); - context.Blueprint.ConstValues.Add(new VariableConstant - { - Name = variable.Name, - Type = variable.Type, - DefaultValue = variable.DefaultValue - }); - Log.Debug("Created ConstNode: Name={ConstName}, Id={NodeId}", constNode.Name, constNode.Id); - } - } - - private void ProcessPubVarBlock(BlockDefinition block, ConversionContext context) - { - foreach (var variable in block.Variables) - { - context.Blueprint.PubVarNames.Add(variable.Name); - } - } - - private void ProcessMainBlock(BlockDefinition block, ConversionContext context) - { - // Create Entry node - var entryNode = new EntryNode - { - X = 50, - Y = 50 - }; - context.Blueprint.AddNode(entryNode); - context.EntryNode = entryNode; - Log.Debug("Created EntryNode: Name={NodeName}, Id={NodeId}", entryNode.Name, entryNode.Id); - - // Process all statements in main block - var mainFlowNodes = new List(); - var currentY = 150.0; - - Console.WriteLine($"[DEBUG] ProcessMainBlock: Starting foreach, Statements.Count = {block.Statements.Count}"); - for (int i = 0; i < block.Statements.Count; i++) - { - var statement = block.Statements[i]; - var sourcePreview = statement.SourceCode?.Length > 50 - ? statement.SourceCode.Substring(0, 50) + "..." - : statement.SourceCode ?? "(null)"; - Console.WriteLine($"[DEBUG] ProcessMainBlock: Processing statement {i}: {statement.GetType().Name}, SourceCode: {sourcePreview}"); - var node = ProcessStatement(statement, context, 50, currentY); - Console.WriteLine($"[DEBUG] ProcessMainBlock: ProcessStatement returned: {node?.Name} ({node?.Id})"); - if (node != null) - { - Console.WriteLine($"[DEBUG] ProcessMainBlock: Adding node {node.Name} ({node.Id}) to blueprint"); - context.Blueprint.AddNode(node); - mainFlowNodes.Add(node); - currentY += 120; - } - } - Console.WriteLine($"[DEBUG] ProcessMainBlock: Finished foreach, mainFlowNodes.Count = {mainFlowNodes.Count}"); - - // Link main flow with Exec connections - LinkNodesWithExec(mainFlowNodes, context.Blueprint); - - Console.WriteLine($"[DEBUG] ProcessMainBlock: mainFlowNodes count = {mainFlowNodes.Count}"); - for (int i = 0; i < mainFlowNodes.Count; i++) - { - Console.WriteLine($"[DEBUG] ProcessMainBlock: mainFlowNodes[{i}] = {mainFlowNodes[i].Name} ({mainFlowNodes[i].Id})"); - } - - // ✅ 处理 Branch/Loop 节点与子块的连接 - // 注意:Loop 节点存储在 LoopBlocks["MainBlock"] 中,不在 MainBlock.Statements - // 因此需要从 context.LoopNodesByParentBlock 中查找 - var flowNode = mainFlowNodes.LastOrDefault(n => n is BranchNode || n is LoopNode); - FlowControlStatement? flowCtrl = null; - - // 如果 mainFlowNodes 中没有找到 Loop,尝试从 LoopBlocks 处理 - if (flowNode == null) - { - // 检查 LoopBlocks 中是否有 MainBlock 的 Loop - if (context.Script.LoopBlocks.TryGetValue("MainBlock", out var loopBlock)) - { - // 获取 Loop FlowControlStatement - flowCtrl = loopBlock.Statements.FirstOrDefault(s => s is FlowControlStatement) as FlowControlStatement; - if (flowCtrl != null) - { - // ✅ 设置 CurrentParentBlock 以便后续 ProcessLoopBlock 复用此 Loop 节点 - context.CurrentParentBlock = "MainBlock"; - - // 在 LoopBlocks["MainBlock"] 中创建 Loop 节点 - var loopNodeInLoopBlock = ProcessFlowControlStatement(flowCtrl, context, 350, 150); - if (loopNodeInLoopBlock is LoopNode loopFromBlock) - { - flowNode = loopFromBlock; - // ✅ 注册到 LoopNodesByParentBlock 以便后续使用 - context.LoopNodesByParentBlock["MainBlock"] = loopFromBlock; - Console.WriteLine($"[DEBUG] ProcessMainBlock: Created Loop node from LoopBlocks, registering as MainBlock's Loop"); - } - } - } - } - else - { - // Branch/Loop 在 MainBlock.Statements 中 - flowCtrl = block.Statements.LastOrDefault(s => s is FlowControlStatement) as FlowControlStatement; - } - - Console.WriteLine($"[DEBUG] ProcessMainBlock: flowNode = {flowNode?.Name} ({flowNode?.Id})"); - if (flowNode != null && flowCtrl != null) - { - Console.WriteLine($"[DEBUG] ProcessMainBlock: Processing flow node connections"); - if (flowNode is BranchNode branch) - { - ProcessBranchNodeConnectionsInMain(branch, flowCtrl, context); - } - else if (flowNode is LoopNode loop) - { - ProcessLoopNodeConnectionsInMain(loop, flowCtrl, context); - } - } - - // Link Entry to first main flow node - if (mainFlowNodes.Count > 0) - { - var firstNode = mainFlowNodes[0]; - var entryExecPin = entryNode.OutputPins.First(p => p.Name == "Exec"); - var firstExecPin = firstNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); - if (entryExecPin != null && firstExecPin != null) - { - context.Blueprint.AddConnection(new BlueprintConnection - { - SourceNodeId = entryNode.Id, - SourcePinId = entryExecPin.Id, - TargetNodeId = firstNode.Id, - TargetPinId = firstExecPin.Id - }); - } - } - } - - private void ProcessBranchNodeConnectionsInMain(BranchNode branch, FlowControlStatement flowCtrl, ConversionContext context) - { - double x = 350; - double targetY = 300; - - // 处理 True 分支 - if (!string.IsNullOrEmpty(flowCtrl.TrueBlockName)) - { - var trueBlock = context.Script.GetBlockByName(flowCtrl.TrueBlockName); - if (trueBlock != null) - { - var firstTrueNode = ProcessBlockRecursive(trueBlock, context, x, targetY); - if (firstTrueNode != null) - { - var truePin = branch.OutputPins.FirstOrDefault(p => p.Name == "True"); - var firstExecIn = firstTrueNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); - if (truePin != null && firstExecIn != null) - { - Console.WriteLine($"[DEBUG] ProcessBranchNodeConnectionsInMain: Creating {branch.Name}.True -> {firstTrueNode.Name}.Exec"); - context.Blueprint.AddConnection(new BlueprintConnection - { - SourceNodeId = branch.Id, - SourcePinId = truePin.Id, - TargetNodeId = firstTrueNode.Id, - TargetPinId = firstExecIn.Id - }); - } - } - } - } - - // 处理 False 分支 - if (!string.IsNullOrEmpty(flowCtrl.FalseBlockName)) - { - var falseBlock = context.Script.GetBlockByName(flowCtrl.FalseBlockName); - if (falseBlock != null) - { - // ✅ 如果 falseBlock 已经访问过,跳过(避免重复创建连接) - if (context.VisitedBlocks.Contains(falseBlock.Name)) - { - Console.WriteLine($"[DEBUG] ProcessBranchNodeConnectionsInMain: falseBlock {falseBlock.Name} already visited, skipping"); - } - else - { - var firstFalseNode = ProcessBlockRecursive(falseBlock, context, x, targetY + 200); - if (firstFalseNode != null && firstFalseNode.Id != branch.Id) - { - var falsePin = branch.OutputPins.FirstOrDefault(p => p.Name == "False"); - var firstExecIn = firstFalseNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); - if (falsePin != null && firstExecIn != null) - { - Console.WriteLine($"[DEBUG] ProcessBranchNodeConnectionsInMain: Creating {branch.Name}.False -> {firstFalseNode.Name}.Exec"); - context.Blueprint.AddConnection(new BlueprintConnection - { - SourceNodeId = branch.Id, - SourcePinId = falsePin.Id, - TargetNodeId = firstFalseNode.Id, - TargetPinId = firstExecIn.Id - }); - } - } - } - } - } - } - - private void ProcessLoopNodeConnectionsInMain(LoopNode loop, FlowControlStatement flowCtrl, ConversionContext context) - { - // ✅ 使用已创建的 loop 节点,不重新创建 - double x = 350; - double targetY = 300; - - // ✅ 处理 LoopBody - if (!string.IsNullOrEmpty(flowCtrl.TrueBlockName)) - { - var loopBodyBlock = context.Script.GetBlockByName(flowCtrl.TrueBlockName); - if (loopBodyBlock != null) - { - // ✅ 同时注册 TrueBlockName 和 ParentBlockName,以便 LoopBodyEnd 查找 - context.LoopNodesByParentBlock[flowCtrl.TrueBlockName] = loop; - if (!string.IsNullOrEmpty(flowCtrl.LoopBodyEndReturnTo)) - { - context.LoopNodesByParentBlock[flowCtrl.LoopBodyEndReturnTo] = loop; - } - - var firstLoopNode = ProcessBlockRecursive(loopBodyBlock, context, x, targetY); - if (firstLoopNode != null) - { - var loopBodyPin = loop.OutputPins.FirstOrDefault(p => p.Name == "LoopBody"); - var firstExecIn = firstLoopNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); - if (loopBodyPin != null && firstExecIn != null) - { - Console.WriteLine($"[DEBUG] ProcessLoopNodeConnectionsInMain: Creating {loop.Name}.LoopBody -> {firstLoopNode.Name}.Exec"); - context.Blueprint.AddConnection(new BlueprintConnection - { - SourceNodeId = loop.Id, - SourcePinId = loopBodyPin.Id, - TargetNodeId = firstLoopNode.Id, - TargetPinId = firstExecIn.Id - }); - } - } - } - } - - // 处理 LoopEnd - if (!string.IsNullOrEmpty(flowCtrl.FalseBlockName)) - { - var afterLoopBlock = context.Script.GetBlockByName(flowCtrl.FalseBlockName); - if (afterLoopBlock != null) - { - var firstAfterLoopNode = ProcessBlockRecursive(afterLoopBlock, context, x, targetY + 200); - if (firstAfterLoopNode != null) - { - var loopEndPin = loop.OutputPins.FirstOrDefault(p => p.Name == "LoopEnd"); - var firstExecIn = firstAfterLoopNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); - if (loopEndPin != null && firstExecIn != null) - { - Console.WriteLine($"[DEBUG] ProcessLoopNodeConnectionsInMain: Creating {loop.Name}.LoopEnd -> {firstAfterLoopNode.Name}.Exec"); - context.Blueprint.AddConnection(new BlueprintConnection - { - SourceNodeId = loop.Id, - SourcePinId = loopEndPin.Id, - TargetNodeId = firstAfterLoopNode.Id, - TargetPinId = firstExecIn.Id - }); - } - } - } - } - - // 处理 LoopBodyEndReturnTo - if (!string.IsNullOrEmpty(flowCtrl.LoopBodyEndReturnTo)) - { - context.LoopNodesByParentBlock[flowCtrl.LoopBodyEndReturnTo] = loop; - } - } - - private BlueprintNode? ProcessStatement(BlockStatement statement, ConversionContext context, double x, double y) - { - Console.WriteLine($"[DEBUG] ProcessStatement: {statement.GetType().Name}"); - switch (statement) - { - case FlowControlStatement flowCtrl: - Console.WriteLine($"[DEBUG] ProcessStatement: FlowControlStatement ControlType={flowCtrl.ControlType}, TrueBlock={flowCtrl.TrueBlockName}, FalseBlock={flowCtrl.FalseBlockName}, SourceCode={flowCtrl.SourceCode}"); - // ✅ NextBlock = Branch/Loop(...) 需要创建节点!不能跳过 - // 因为 Branch/Loop 节点有输出引脚需要连接到子块 - if ((flowCtrl.ControlType == FlowControlType.Branch || flowCtrl.ControlType == FlowControlType.Loop) && - !string.IsNullOrEmpty(flowCtrl.TrueBlockName) && - !string.IsNullOrEmpty(flowCtrl.FalseBlockName)) - { - // ✅ 对于 Loop 节点,检查是否已存在(避免重复创建) - if (flowCtrl.ControlType == FlowControlType.Loop && - context.CurrentParentBlock != null && - context.LoopNodesByParentBlock.TryGetValue(context.CurrentParentBlock, out var existingLoop)) - { - Console.WriteLine($"[DEBUG] ProcessStatement: Reusing existing Loop node for {context.CurrentParentBlock}"); - context.LastProcessedNode = existingLoop; - return existingLoop; - } - - Console.WriteLine($"[DEBUG] ProcessStatement: Creating {flowCtrl.ControlType} node for NextBlock = {flowCtrl.ControlType}(...)"); - var flowNode = ProcessFlowControlStatement(flowCtrl, context, x, y); - if (flowNode != null) - { - context.LastProcessedNode = flowNode; - } - return flowNode; - } - if (flowCtrl.ControlType == FlowControlType.LoopBodyEnd) - { - HandleLoopBodyEnd(flowCtrl, context); - return null; - } - var otherFlowNode = ProcessFlowControlStatement(flowCtrl, context, x, y); - if (otherFlowNode != null) - { - context.LastProcessedNode = otherFlowNode; - } - return otherFlowNode; - - case ExpressionStatement expr: - if (expr.Expression.StartsWith("NextBlock = ")) - return null; - var actionNode = CreateActionNodeFromExpression(expr.Expression, context); - if (actionNode != null) - { - context.LastProcessedNode = actionNode; - } - return actionNode; - - default: - return null; - } - } - - private void HandleLoopBodyEnd(FlowControlStatement flowCtrl, ConversionContext context) - { - if (!string.IsNullOrEmpty(flowCtrl.LoopBodyEndReturnTo)) - { - if (context.LoopNodesByParentBlock.TryGetValue(flowCtrl.LoopBodyEndReturnTo, out var loopNode)) - { - var lastNode = context.LastProcessedNode; - - if (lastNode != null) - { - // 查找 BLE 节点(通过 Loop.Condition 的数据连接,或通过 VariableSources 中条件变量的来源) - var loopConditionIn = loopNode.InputPins.FirstOrDefault(p => p.Name == "Condition"); - BlueprintNode? bleNode = null; - - // 方案1:通过 Loop.Condition 的数据连接查找 - if (loopConditionIn != null) - { - var conditionConn = context.Blueprint.Connections - .FirstOrDefault(c => c.TargetNodeId == loopNode.Id && - c.TargetPinId == loopConditionIn.Id); - if (conditionConn != null) - { - bleNode = context.Blueprint.Nodes.FirstOrDefault(n => n.Id == conditionConn.SourceNodeId); - } - } - - // 方案2:通过 VariableSources 中条件变量的来源查找 - if (bleNode == null && !string.IsNullOrEmpty(flowCtrl.ConditionExpression)) - { - var conditionVarName = flowCtrl.ConditionExpression.Trim(); - var source = context.VariableSources.Values - .FirstOrDefault(s => s.PubVarName == conditionVarName); - if (source != null) - { - bleNode = source.Node; - } - } - - // 如果找到了 BLE 节点,建立完整的执行流回连 - if (bleNode != null) - { - var lastExecOut = lastNode.OutputPins.FirstOrDefault(p => p.Name == "Exec"); - var bleExecIn = bleNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); - var bleExecOut = bleNode.OutputPins.FirstOrDefault(p => p.Name == "Exec"); - var loopExecIn = loopNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); - - // lastNode.Exec -> BLE.Exec - if (lastExecOut != null && bleExecIn != null && lastNode.Id != bleNode.Id) - { - var hasConnection = context.Blueprint.Connections - .Any(c => c.SourceNodeId == lastNode.Id && - c.SourcePinId == lastExecOut.Id && - c.TargetNodeId == bleNode.Id && - c.TargetPinId == bleExecIn.Id); - if (!hasConnection) - { - context.Blueprint.AddConnection(new BlueprintConnection - { - SourceNodeId = lastNode.Id, - SourcePinId = lastExecOut.Id, - TargetNodeId = bleNode.Id, - TargetPinId = bleExecIn.Id - }); - } - } - - // BLE.Exec -> Loop.Exec - if (bleExecOut != null && loopExecIn != null) - { - var hasConnection = context.Blueprint.Connections - .Any(c => c.SourceNodeId == bleNode.Id && - c.SourcePinId == bleExecOut.Id && - c.TargetNodeId == loopNode.Id && - c.TargetPinId == loopExecIn.Id); - if (!hasConnection) - { - context.Blueprint.AddConnection(new BlueprintConnection - { - SourceNodeId = bleNode.Id, - SourcePinId = bleExecOut.Id, - TargetNodeId = loopNode.Id, - TargetPinId = loopExecIn.Id - }); - } - } - } - } - - context.LoopNodesByParentBlock[flowCtrl.LoopBodyEndReturnTo] = loopNode; - } - } - } - private BlueprintNode? ProcessFlowControlStatement(FlowControlStatement flowCtrl, ConversionContext context, double x, double y) - { - var node = CreateControlFlowNode(flowCtrl, context); - if (node == null) return null; - - node.X = x; - node.Y = y; - - // ✅ 不在这里添加到 blueprint,由调用者(ProcessStatement)负责添加 - // 这样可以避免重复添加 - - if (!string.IsNullOrEmpty(flowCtrl.ConditionExpression)) - { - CreateDataConnectionsForExpression(flowCtrl.ConditionExpression, node, context); - } - - double targetY = y + 150; - - switch (flowCtrl.ControlType) - { - case FlowControlType.Branch: - // ✅ 不在这里处理子块连接,由 ProcessBlockRecursive 通过 NextBlock 属性统一处理 - // 这样可以避免重复处理和 visited 问题 - Console.WriteLine($"[DEBUG] ProcessFlowControlStatement: Branch node created, TrueBlock={flowCtrl.TrueBlockName}, FalseBlock={flowCtrl.FalseBlockName}"); - break; - - case FlowControlType.Loop: - // ✅ 不在这里处理子块连接,由 ProcessBlockRecursive 通过 NextBlock 属性统一处理 - Console.WriteLine($"[DEBUG] ProcessFlowControlStatement: Loop node created, LoopBody={flowCtrl.TrueBlockName}, LoopEnd={flowCtrl.FalseBlockName}"); - break; - } - - return node; - } - - private BlueprintNode? ProcessBlockRecursive(BlockDefinition block, ConversionContext context, double x, double y) - { - Console.WriteLine($"[DEBUG] ProcessBlockRecursive: block={block.Name}, type={block.Type}, statements={block.Statements.Count}"); - - // 如果块已经访问过,返回第一个节点用于建立连接(而不是null) - if (context.VisitedBlocks.Contains(block.Name)) - { - var cachedFirstNode = context.BlockFirstNodes.GetValueOrDefault(block.Name); - Console.WriteLine($"[DEBUG] ProcessBlockRecursive: block {block.Name} ALREADY VISITED, returning {(cachedFirstNode != null ? cachedFirstNode.Name : "null")}"); - return cachedFirstNode; - } - context.VisitedBlocks.Add(block.Name); - - var nodes = new List(); - var currentY = y; - - Console.WriteLine($"[DEBUG] ProcessBlockRecursive: Processing {block.Statements.Count} statements for block {block.Name}"); - // 首先处理所有可执行语句节点 - foreach (var statement in block.Statements) - { - Console.WriteLine($"[DEBUG] ProcessBlockRecursive: Processing statement: {statement.GetType().Name}"); - var node = ProcessStatement(statement, context, x, currentY); - Console.WriteLine($"[DEBUG] ProcessBlockRecursive: ProcessStatement returned: {node?.Name} ({node?.Id})"); - if (node != null) - { - // ✅ 检查节点是否已经存在于 Blueprint 中,避免重复添加 - if (!context.Blueprint.Nodes.Any(n => n.Id == node.Id)) - { - Console.WriteLine($"[DEBUG] ProcessBlockRecursive: Adding node {node.Name} ({node.Id}) to blueprint"); - context.Blueprint.AddNode(node); - } - else - { - Console.WriteLine($"[DEBUG] ProcessBlockRecursive: Node {node.Name} ({node.Id}) already in blueprint, skipping"); - } - nodes.Add(node); - currentY += 120; - - if (context.PendingNodesForExecChain.Count > 0) - { - var parentNodeIndex = nodes.Count - 1; - foreach (var pendingNode in context.PendingNodesForExecChain) - { - if (!nodes.Any(n => n.Id == pendingNode.Id)) - { - nodes.Insert(parentNodeIndex + 1, pendingNode); - parentNodeIndex++; - } - } - context.PendingNodesForExecChain.Clear(); - } - } - } - Console.WriteLine($"[DEBUG] ProcessBlockRecursive: After processing statements, nodes.Count = {nodes.Count}"); - - // 如果块只有路由语句(nodes为空),仍然需要处理路由以建立连接 - if (nodes.Count == 0 && block.Statements.Count > 0) - { - var firstStatement = block.Statements[0]; - if (firstStatement is FlowControlStatement flowCtrl && - (flowCtrl.ControlType == FlowControlType.Branch || flowCtrl.ControlType == FlowControlType.Loop)) - { - // 这是一个纯路由块,需要处理路由语句以建立连接 - Console.WriteLine($"[DEBUG] ProcessBlockRecursive: Processing routing-only block {block.Name}"); - var routingNode = ProcessFlowControlStatement(flowCtrl, context, x, y); - if (routingNode != null) - { - context.Blueprint.AddNode(routingNode); - nodes.Add(routingNode); - Console.WriteLine($"[DEBUG] ProcessBlockRecursive: Added routing node {routingNode.Name} ({routingNode.Id}) to nodes list"); - } - } - } - - LinkNodesWithExec(nodes, context.Blueprint); - - // ✅ 标记为已访问 - context.VisitedBlocks.Add(block.Name); - - // ✅ 处理 Branch/Loop 节点与子块的连接 - var flowNode = nodes.LastOrDefault(n => n is BranchNode || n is LoopNode); - if (flowNode != null) - { - Console.WriteLine($"[DEBUG] ProcessBlockRecursive: Processing flow node connections for {block.Name}"); - if (flowNode is BranchNode branch) - { - ProcessBranchNodeConnections(branch, block, context, x, y); - } - else if (flowNode is LoopNode loop) - { - ProcessLoopNodeConnections(loop, block, context, x, y); - } - } - - // 处理跨块连接 - if (!string.IsNullOrEmpty(block.NextBlockName) && - block.Type != BlockType.LoopBlock) - { - var nextBlock = context.Script.GetBlockByName(block.NextBlockName); - if (nextBlock != null && !context.VisitedBlocks.Contains(nextBlock.Name)) - { - if (nodes.Count > 0) - { - var lastNode = nodes.Last(); - var nextFirstNode = ProcessBlockRecursive(nextBlock, context, x + 300, y); - if (nextFirstNode != null) - { - var lastExecOut = lastNode.OutputPins.FirstOrDefault(p => p.Name == "Exec"); - var nextExecIn = nextFirstNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); - - // ✅ 验证是有效的 Exec 连接 - if (lastExecOut == null || lastExecOut.Direction != PinDirection.Output) - { - Log.Warning("Cannot create cross-block Exec connection: {Node}.Exec is not a valid output pin", - lastNode.Name); - } - else if (lastNode.Id == nextFirstNode.Id) - { - Log.Warning("Skipping self-loop cross-block Exec connection"); - } - else if (lastExecOut != null && nextExecIn != null) - { - context.Blueprint.AddConnection(new BlueprintConnection - { - SourceNodeId = lastNode.Id, - SourcePinId = lastExecOut.Id, - TargetNodeId = nextFirstNode.Id, - TargetPinId = nextExecIn.Id - }); - } - } - } - } - } - - // 存储块的第一个节点,以便后续已访问块的连接 - var firstNode = nodes.FirstOrDefault(); - if (firstNode != null) - { - context.BlockFirstNodes[block.Name] = firstNode; - } - - return firstNode; - } - - private void ProcessBranchNodeConnections(BranchNode branch, BlockDefinition block, ConversionContext context, double x, double y) - { - var callId = Guid.NewGuid().ToString().Substring(0, 8); - Console.WriteLine($"[DEBUG] ProcessBranchNodeConnections ENTER: callId={callId}, branch={branch.Id.Substring(0,8)}, block={block.Name}"); - // 找到对应的 FlowControlStatement - var flowCtrl = block.Statements.FirstOrDefault(s => s is FlowControlStatement) as FlowControlStatement; - if (flowCtrl == null) return; - - double targetY = y + 150; - - // 处理 True 分支 - if (!string.IsNullOrEmpty(flowCtrl.TrueBlockName)) - { - var trueBlock = context.Script.GetBlockByName(flowCtrl.TrueBlockName); - // ✅ 如果 trueBlock 已经访问过,跳过(避免重复创建连接) - if (trueBlock != null && context.VisitedBlocks.Contains(trueBlock.Name)) - { - Console.WriteLine($"[DEBUG] ProcessBranchNodeConnections[{callId}]: trueBlock {trueBlock.Name} already visited, skipping"); - } - else if (trueBlock != null) - { - var firstTrueNode = ProcessBlockRecursive(trueBlock, context, x + 300, targetY); - if (firstTrueNode != null && firstTrueNode.Id != branch.Id) - { - var truePin = branch.OutputPins.FirstOrDefault(p => p.Name == "True"); - var firstExecIn = firstTrueNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); - if (truePin != null && firstExecIn != null) - { - Console.WriteLine($"[DEBUG] ProcessBranchNodeConnections: Creating {branch.Name}.True -> {firstTrueNode.Name}.Exec"); - context.Blueprint.AddConnection(new BlueprintConnection - { - SourceNodeId = branch.Id, - SourcePinId = truePin.Id, - TargetNodeId = firstTrueNode.Id, - TargetPinId = firstExecIn.Id - }); - } - } - } - } - - // 处理 False 分支 - if (!string.IsNullOrEmpty(flowCtrl.FalseBlockName)) - { - var falseBlock = context.Script.GetBlockByName(flowCtrl.FalseBlockName); - if (falseBlock != null) - { - // ✅ 无论块是否已访问,都尝试获取第一个节点并建立连接 - // 对于已访问的块,从 BlockFirstNodes 获取;对于未访问的块,调用 ProcessBlockRecursive - BlueprintNode? firstFalseNode; - if (context.VisitedBlocks.Contains(falseBlock.Name)) - { - firstFalseNode = context.BlockFirstNodes.GetValueOrDefault(falseBlock.Name); - Console.WriteLine($"[DEBUG] ProcessBranchNodeConnections: falseBlock {falseBlock.Name} already visited, got first node from cache"); - } - else - { - firstFalseNode = ProcessBlockRecursive(falseBlock, context, x + 300, targetY + 200); - } - - Console.WriteLine($"[DEBUG] ProcessBranchNodeConnections: firstFalseNode = {firstFalseNode?.Name} ({firstFalseNode?.Id?.Substring(0,8) ?? "null"}), branch.Id = {branch.Id.Substring(0,8)}"); - - if (firstFalseNode == null) - { - Console.WriteLine($"[DEBUG] ProcessBranchNodeConnections: firstFalseNode is null, skipping"); - } - else if (firstFalseNode.Id == branch.Id) - { - Console.WriteLine($"[DEBUG] ProcessBranchNodeConnections: Skipping self-loop"); - } - else if (!context.Blueprint.Nodes.Any(n => n.Id == firstFalseNode.Id)) - { - Console.WriteLine($"[DEBUG] ProcessBranchNodeConnections: firstFalseNode NOT FOUND in Blueprint, skipping"); - } - else - { - // ✅ 如果目标是路由块(Branch/Loop),获取其实际可连接的子节点 - var actualTarget = GetFirstConnectableNode(firstFalseNode, context); - var falsePin = branch.OutputPins.FirstOrDefault(p => p.Name == "False"); - var actualExecIn = actualTarget?.InputPins.FirstOrDefault(p => p.Name == "Exec"); - - if (falsePin != null && actualExecIn != null && actualTarget != null && actualTarget.Id != branch.Id) - { - Console.WriteLine($"[DEBUG] ProcessBranchNodeConnections: Creating {branch.Name}.False -> {actualTarget.Name}.Exec"); - context.Blueprint.AddConnection(new BlueprintConnection - { - SourceNodeId = branch.Id, - SourcePinId = falsePin.Id, - TargetNodeId = actualTarget.Id, - TargetPinId = actualExecIn.Id - }); - } - } - } - } - - Console.WriteLine($"[DEBUG] ProcessBranchNodeConnections EXIT: callId={callId}, branch={branch.Id.Substring(0,8)}"); - } - - /// - /// 获取节点的可连接子节点。如果节点本身有来自外部(跨块)的 Exec 输入引脚连接,直接返回; - /// 如果是路由块(Branch/Loop)且其 Exec 输入只被内部连接,使用,则需要找到其连接的目标节点。 - /// - private BlueprintNode? GetFirstConnectableNode(BlueprintNode node, ConversionContext context) - { - // 如果节点本身有来自外部的 Exec 输入引脚连接,直接返回 - var execInputPin = node.InputPins.FirstOrDefault(p => p.Name == "Exec"); - if (execInputPin != null) - { - // 检查是否有从其他节点到这个节点 Exec 输入的连接(外部连接) - var hasExternalConnection = context.Blueprint.Connections - .Any(c => c.TargetNodeId == node.Id && - c.TargetPinId == execInputPin.Id && - c.SourceNodeId != node.Id); - if (hasExternalConnection) - { - return node; - } - } - - // 如果是 Branch 节点,找到其 True 输出连接的目标节点 - if (node is BranchNode branch) - { - var trueConn = context.Blueprint.Connections - .FirstOrDefault(c => c.SourceNodeId == branch.Id && - branch.OutputPins.Any(p => p.Id == c.SourcePinId && p.Name == "True")); - if (trueConn != null) - { - var targetNode = context.Blueprint.GetNodeById(trueConn.TargetNodeId); - if (targetNode != null) - { - // 递归获取实际可连接的节点 - return GetFirstConnectableNode(targetNode, context); - } - } - } - - // 如果是 Loop 节点,找到其 LoopBody 输出连接的目标节点 - if (node is LoopNode loop) - { - var loopBodyConn = context.Blueprint.Connections - .FirstOrDefault(c => c.SourceNodeId == loop.Id && - loop.OutputPins.Any(p => p.Id == c.SourcePinId && p.Name == "LoopBody")); - if (loopBodyConn != null) - { - var targetNode = context.Blueprint.GetNodeById(loopBodyConn.TargetNodeId); - if (targetNode != null) - { - return GetFirstConnectableNode(targetNode, context); - } - } - } - - return node; // 回退 - } - - private void ProcessLoopNodeConnections(LoopNode loop, BlockDefinition block, ConversionContext context, double x, double y) - { - // 找到对应的 FlowControlStatement - var flowCtrl = block.Statements.FirstOrDefault(s => s is FlowControlStatement) as FlowControlStatement; - if (flowCtrl == null) return; - - double targetY = y + 150; - - // 处理 LoopBody - if (!string.IsNullOrEmpty(flowCtrl.TrueBlockName)) - { - var loopBodyBlock = context.Script.GetBlockByName(flowCtrl.TrueBlockName); - if (loopBodyBlock != null) - { - // 注册 Loop 节点以便 LoopBodyEnd 引用 - context.LoopNodesByParentBlock[flowCtrl.TrueBlockName] = loop; - - var firstLoopNode = ProcessBlockRecursive(loopBodyBlock, context, x + 300, targetY); - if (firstLoopNode != null) - { - var loopBodyPin = loop.OutputPins.FirstOrDefault(p => p.Name == "LoopBody"); - var firstExecIn = firstLoopNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); - if (loopBodyPin != null && firstExecIn != null) - { - Console.WriteLine($"[DEBUG] ProcessLoopNodeConnections: Creating {loop.Name}.LoopBody -> {firstLoopNode.Name}.Exec"); - context.Blueprint.AddConnection(new BlueprintConnection - { - SourceNodeId = loop.Id, - SourcePinId = loopBodyPin.Id, - TargetNodeId = firstLoopNode.Id, - TargetPinId = firstExecIn.Id - }); - } - } - } - } - - // 处理 LoopEnd - if (!string.IsNullOrEmpty(flowCtrl.FalseBlockName)) - { - var afterLoopBlock = context.Script.GetBlockByName(flowCtrl.FalseBlockName); - if (afterLoopBlock != null) - { - var firstAfterLoopNode = ProcessBlockRecursive(afterLoopBlock, context, x + 300, targetY + 200); - if (firstAfterLoopNode != null) - { - var loopEndPin = loop.OutputPins.FirstOrDefault(p => p.Name == "LoopEnd"); - var firstExecIn = firstAfterLoopNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); - if (loopEndPin != null && firstExecIn != null) - { - Console.WriteLine($"[DEBUG] ProcessLoopNodeConnections: Creating {loop.Name}.LoopEnd -> {firstAfterLoopNode.Name}.Exec"); - context.Blueprint.AddConnection(new BlueprintConnection - { - SourceNodeId = loop.Id, - SourcePinId = loopEndPin.Id, - TargetNodeId = firstAfterLoopNode.Id, - TargetPinId = firstExecIn.Id - }); - } - } - } - } - - // 处理 LoopBodyEndReturnTo - if (!string.IsNullOrEmpty(flowCtrl.LoopBodyEndReturnTo)) - { - context.LoopNodesByParentBlock[flowCtrl.LoopBodyEndReturnTo] = loop; - } - } - - private BlueprintNode? CreateControlFlowNode(FlowControlStatement statement, ConversionContext context) - { - switch (statement.ControlType) - { - case FlowControlType.Branch: - return new BranchNode(); - case FlowControlType.Loop: - return new LoopNode(); - case FlowControlType.Break: - return new BreakNode(); - default: - return null; - } - } - - private static string GetMethodNameFromExpression(InvocationExpressionSyntax invoke) - { - if (invoke.Expression is IdentifierNameSyntax id) - return id.Identifier.Text; - if (invoke.Expression is GenericNameSyntax generic) - return generic.Identifier.Text; - if (invoke.Expression is MemberAccessExpressionSyntax member) - return member.Name.Identifier.Text; - return string.Empty; - } - - private BlueprintNode? CreateActionNodeFromExpression(string expression, ConversionContext context) - { - var parsed = TryParseInvocationExpression(expression); - if (parsed == null) - return null; - - var (funcName, args, assignment) = parsed.Value; - - if (funcName == "Get") - { - var varName = args?.Arguments.FirstOrDefault()?.Expression.ToString() ?? string.Empty; - - // ✅ 始终为 Get 创建独立的 GetNode - // 注意:不在此处添加到 Blueprint,由 ProcessBlockRecursive 统一添加 - var getNode = new GetNode - { - VarName = varName, - Name = $"Get:{varName}" - }; - Log.Debug("Created GetNode: VarName={VarName}, Id={NodeId}", varName, getNode.Id); - - var getValuePin = getNode.OutputPins.First(p => p.Name == "Value"); - - // ✅ 如果是赋值给 PubVar,注册数据源(使用唯一 key) - if (assignment != null) - { - var assignedVarName = assignment.Left.ToString(); - if (assignedVarName != "_") - { - var isPubVar = context.Script.PubVarBlock?.Variables - .Any(v => v.Name == assignedVarName) == true; - // ✅ 使用唯一 key(包含 Guid),避免嵌套复用 - context.VariableSources[$"__get_{assignedVarName}_{Guid.NewGuid():N}__"] = new VariableSource - { - Node = getNode, - Pin = getValuePin, - PubVarName = isPubVar ? assignedVarName : null - }; - Log.Debug("Registered GetNode output as VariableSource: {VarName}, IsPubVar={IsPubVar}", - assignedVarName, isPubVar); - } - } - - return getNode; // Return immediately — do NOT fall through to the CallNode block below - } - - if (funcName == "Set") - { - var argList = args?.Arguments.ToList() ?? new List(); - if (argList.Count >= 2) - { - var varName = argList[0].Expression.ToString(); - var valueExpr = argList[1].Expression; // ✅ 使用ExpressionSyntax而非ToString() - // 注意:不在此处添加到 Blueprint,由 ProcessBlockRecursive 统一添加 - var setNode = new SetNode - { - VarName = varName, - Name = $"Set:{varName}" - }; - Log.Debug("Created SetNode: VarName={VarName}, Id={NodeId}", varName, setNode.Id); - - // ✅ 获取Set节点的Value输入引脚 - var setValuePin = setNode.InputPins.FirstOrDefault(p => p.Name == "Value"); - if (setValuePin != null && valueExpr != null) - { - // ✅ 使用ProcessArgumentExpression处理嵌套函数调用 - ProcessArgumentExpression(valueExpr, setValuePin, setNode, context); - } - - return setNode; // Return immediately — do NOT fall through to the CallNode block below - } - return null; - } - - if (funcName == "Print") - { - var printNode = new PrintNode(); - var arg = args?.Arguments.FirstOrDefault()?.Expression.ToString() ?? string.Empty; - CreateDataConnectionsForExpression(arg, printNode, context); - return printNode; - } - - if (funcName == "Pause") - { - var pauseNode = new PauseNode(); - var arg = args?.Arguments.FirstOrDefault()?.Expression.ToString() ?? string.Empty; - CreateDataConnectionsForExpression(arg, pauseNode, context); - return pauseNode; - } - - if (assignment != null) - { - var varName = assignment.Left.ToString(); - - if (funcName is "Loop" or "Branch" or "LoopBodyEnd" or "Break") - { - Log.Debug("Skipped creating CallNode for flow control function: {FuncName}", funcName); - return null; - } - - // ✅ 检查是否应该复用已有的 VariableSource - // 如果 varName 已经有一个来源(相同函数的调用),则复用;否则创建新的 - var existingSource = context.VariableSources.Values - .FirstOrDefault(s => s.PubVarName == varName); - - BlueprintNode callNode; - if (existingSource != null) - { - // ✅ 复用已有的源节点 - callNode = existingSource.Node; - Log.Debug("Reused existing source node {NodeName} for {VarName}", callNode.Name, varName); - } - else - { - // ✅ 创建新的 Call 节点 - var isHelper = context.Script.HelperFunctions.Any(h => h.Name == funcName); - if (isHelper) - callNode = new CallHelperNode { HelperFunctionName = funcName, Name = $"Helper:{funcName}" }; - else - callNode = new CallNode { FunctionName = funcName, Name = $"Call:{funcName}" }; - - AddParameterPins(callNode, args?.Arguments); - CreateDataConnectionsForArguments(args?.Arguments, callNode, context); - - // ✅ 注册新的数据源 - var returnPin = callNode.OutputPins.FirstOrDefault(p => p.Name == "Return"); - if (returnPin != null) - { - // Check if varName (assignment target) is a PubVar - var isPubVar = context.Script.PubVarBlock?.Variables - .Any(v => v.Name == varName) == true; - context.VariableSources[varName] = new VariableSource - { - Node = callNode, - Pin = returnPin, - PubVarName = isPubVar ? varName : null // ✅ Set PubVarName for PubVar - }; - } - } - - return callNode; - } - - if (funcName is "Loop" or "Branch" or "LoopBodyEnd" or "Break") - { - Log.Debug("Skipped creating CallNode for flow control/builtin function: {FuncName}", funcName); - return null; - } - - BlueprintNode callNode2; - var isHelper2 = context.Script.HelperFunctions.Any(h => h.Name == funcName); - if (isHelper2) - callNode2 = new CallHelperNode { HelperFunctionName = funcName, Name = $"Helper:{funcName}" }; - else - callNode2 = new CallNode { FunctionName = funcName, Name = $"Call:{funcName}" }; - - AddParameterPins(callNode2, args?.Arguments); - CreateDataConnectionsForArguments(args?.Arguments, callNode2, context); - - return callNode2; - } - - private (string? funcName, ArgumentListSyntax? args, AssignmentExpressionSyntax? assignment)? TryParseInvocationExpression(string expression) - { - var wrappedCode = $"_ = {expression};"; - var syntaxTree = CSharpSyntaxTree.ParseText(wrappedCode, cancellationToken: CancellationToken.None); - var root = syntaxTree.GetCompilationUnitRoot(); - - var globalStmt = root.Members.FirstOrDefault() as GlobalStatementSyntax; - var stmt = globalStmt?.Statement as ExpressionStatementSyntax; - - if (stmt?.Expression is AssignmentExpressionSyntax outerAssignment) - { - ExpressionSyntax rightExpr = outerAssignment.Right; - // Track the innermost assignment that has a non-underscore variable on the left - AssignmentExpressionSyntax? innermostAssignment = null; - while (rightExpr is AssignmentExpressionSyntax nestedAssignment) - { - innermostAssignment = nestedAssignment; - rightExpr = nestedAssignment.Right; - } - - if (rightExpr is InvocationExpressionSyntax invoke) - { - var methodName = GetMethodNameFromExpression(invoke); - // Use the innermost assignment if available, otherwise the outermost - var assignmentToReturn = innermostAssignment ?? outerAssignment; - return (methodName, invoke.ArgumentList, assignmentToReturn); - } - } - else if (stmt?.Expression is InvocationExpressionSyntax directInvoke) - { - var methodName = GetMethodNameFromExpression(directInvoke); - return (methodName, directInvoke.ArgumentList, null); - } - return null; - } - - private void AddParameterPins(BlueprintNode node, SeparatedSyntaxList? arguments) - { - if (!arguments.HasValue || arguments.Value.Count == 0) return; - - foreach (var arg in arguments.Value) - { - node.InputPins.Add(new BlueprintPin - { - Name = $"param{node.InputPins.Count}", - Direction = PinDirection.Input, - Type = PinType.Any - }); - } - } - - private void ProcessLoopBlock(BlockDefinition block, ConversionContext context) - { - if (context.VisitedBlocks.Contains(block.Name)) return; - context.VisitedBlocks.Add(block.Name); - - // ✅ 设置当前父块名称,以便复用 Loop 节点 - var parentBlockName = block.ParentBlockName ?? block.Name; - context.CurrentParentBlock = parentBlockName; - - var nodes = new List(); - var currentY = 50.0; - - foreach (var statement in block.Statements) - { - var node = ProcessStatement(statement, context, 50, currentY); - if (node != null) - { - context.Blueprint.AddNode(node); - nodes.Add(node); - currentY += 120; - } - } - - LinkNodesWithExec(nodes, context.Blueprint); - - foreach (var statement in block.Statements) - { - if (statement is FlowControlStatement flowCtrl) - { - if (!string.IsNullOrEmpty(flowCtrl.TrueBlockName)) - { - var trueBlock = context.Script.GetBlockByName(flowCtrl.TrueBlockName); - if (trueBlock != null) - ProcessBlockRecursive(trueBlock, context, 350, 50); - } - if (!string.IsNullOrEmpty(flowCtrl.FalseBlockName)) - { - var falseBlock = context.Script.GetBlockByName(flowCtrl.FalseBlockName); - if (falseBlock != null) - ProcessBlockRecursive(falseBlock, context, 350, 200); - } - } - } - } - - private void LinkNodesWithExec(List nodes, Contract.Workflow.Blueprint blueprint) - { - Console.WriteLine($"[DEBUG] === LinkNodesWithExec called with {nodes.Count} nodes: {string.Join(", ", nodes.Select(n => $"{n.Name}({n.Id.Substring(0,8)})"))}"); - - for (int i = 0; i < nodes.Count - 1; i++) - { - var currentNode = nodes[i]; - var nextNode = nodes[i + 1]; - - // ✅ 跳过自环连接 - if (currentNode.Id == nextNode.Id) - { - Console.WriteLine($"[DEBUG] LinkNodesWithExec: Skipping self-loop for {currentNode.Name} ({currentNode.Id.Substring(0,8)})"); - continue; - } - - // ✅ 获取 currentNode 的 Exec 输出引脚 - var currentExecOut = currentNode.OutputPins.FirstOrDefault(p => p.Name == "Exec"); - - // ✅ Branch 和 Loop 节点没有 Exec 输出引脚,跳过 - if (currentExecOut == null) - { - Log.Debug("Skipping Exec link: {Node} has no Exec output pin", currentNode.Name); - continue; - } - - // ✅ 确保 currentExecOut 是 OUTPUT 类型的引脚 - if (currentExecOut.Direction != PinDirection.Output) - { - Log.Warning("Cannot create Exec connection: {Node}.Exec is not a valid output pin", currentNode.Name); - continue; - } - - // ✅ 获取 nextNode 的 Exec 输入引脚 - var nextExecIn = nextNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); - if (nextExecIn == null) - { - Log.Warning("Cannot create Exec connection: {Node} has no Exec input pin", nextNode.Name); - continue; - } - - Console.WriteLine($"[DEBUG] LinkNodesWithExec: Creating {currentNode.Name}.{currentExecOut.Name} -> {nextNode.Name}.{nextExecIn.Name}"); - - blueprint.AddConnection(new BlueprintConnection - { - SourceNodeId = currentNode.Id, - SourcePinId = currentExecOut.Id, - TargetNodeId = nextNode.Id, - TargetPinId = nextExecIn.Id - }); - } - } - - private void LayoutNodes(Contract.Workflow.Blueprint blueprint) - { - var depths = new Dictionary(); - var visited = new HashSet(); - - var entry = blueprint.Nodes.FirstOrDefault(n => n.NodeType == BlueprintNodeType.Entry); - if (entry != null) - { - depths[entry.Id] = 0; - CalculateDepths(blueprint, entry, depths, visited); - } - - var depthGroups = new Dictionary>(); - foreach (var node in blueprint.Nodes) - { - if (depths.TryGetValue(node.Id, out var depth)) - { - if (!depthGroups.ContainsKey(depth)) - depthGroups[depth] = new List(); - depthGroups[depth].Add(node); - } - } - - foreach (var group in depthGroups) - { - var depth = group.Key; - var nodes = group.Value; - for (int i = 0; i < nodes.Count; i++) - { - nodes[i].X = 50 + depth * 300; - nodes[i].Y = 50 + i * 120; - } - } - } - - private void CalculateDepths(Contract.Workflow.Blueprint blueprint, BlueprintNode node, Dictionary depths, HashSet visited) - { - if (visited.Contains(node.Id)) return; - visited.Add(node.Id); - - var outConnections = blueprint.GetConnectionsFrom(node.Id); - foreach (var conn in outConnections) - { - if (blueprint.GetNodeById(conn.TargetNodeId) is { } targetNode) - { - var currentDepth = depths[node.Id]; - if (!depths.ContainsKey(targetNode.Id) || depths[targetNode.Id] < currentDepth + 1) - { - depths[targetNode.Id] = currentDepth + 1; - } - CalculateDepths(blueprint, targetNode, depths, visited); - } - } - } - - private bool IsVariableReference(string token) - { - if (string.IsNullOrWhiteSpace(token)) return false; - if (bool.TryParse(token, out _)) return false; - if (int.TryParse(token, out _) || double.TryParse(token, out _)) return false; - if (token.StartsWith("\"") && token.EndsWith("\"")) return false; - if (token.StartsWith("'") && token.EndsWith("'")) return false; - if (token.Contains('(')) return false; - return System.Text.RegularExpressions.Regex.IsMatch(token, @"^[a-zA-Z_]\w*$"); - } - - private void CreateDataConnectionsForExpression(string expression, BlueprintNode targetNode, ConversionContext context) - { - var wrappedCode = $"_ = {expression};"; - var syntaxTree = CSharpSyntaxTree.ParseText(wrappedCode, cancellationToken: CancellationToken.None); - var root = syntaxTree.GetCompilationUnitRoot(); - var globalStmt = root.Members.FirstOrDefault() as GlobalStatementSyntax; - var stmt = globalStmt?.Statement as ExpressionStatementSyntax; - - if (stmt?.Expression == null) return; - - ProcessExpressionForDataConnections(stmt.Expression, targetNode, context); - } - - private void ProcessExpressionForDataConnections(ExpressionSyntax expr, BlueprintNode targetNode, ConversionContext context) - { - switch (expr) - { - case LiteralExpressionSyntax literal: - CreateConstNodeForLiteral(literal, targetNode, context); - break; - - case IdentifierNameSyntax identifier: - CreateDataConnectionForVariable(identifier.Identifier.Text, targetNode, context); - break; - - case AssignmentExpressionSyntax assignment: - ProcessExpressionForDataConnections(assignment.Right, targetNode, context); - break; - - case InvocationExpressionSyntax invoke: - var funcName = GetMethodNameFromExpression(invoke); - - // ✅ 注意:Get 和 Set 的处理在 ProcessArgumentExpression 中已正确完成 - // 这里只需要处理其他函数调用(HelperFuncAdd 等)的情况 - if (funcName != "Get" && funcName != "Set") - { - // ✅ 始终创建新的 Call 节点(不复用) - // 因为 condition 表达式(BEQ/BLT/BLE)是不同的函数调用,不应该复用 - var isHelper = context.Script.HelperFunctions.Any(h => h.Name == funcName); - BlueprintNode callNode; - if (isHelper) - callNode = new CallHelperNode { HelperFunctionName = funcName, Name = $"Helper:{funcName}" }; - else - callNode = new CallNode { FunctionName = funcName, Name = $"Call:{funcName}" }; - - AddParameterPins(callNode, invoke.ArgumentList.Arguments); - context.Blueprint.AddNode(callNode); - - // ✅ 处理参数的连接 - CreateDataConnectionsForArguments(invoke.ArgumentList.Arguments, callNode, context); - - // ✅ 将函数调用的返回值连接到 targetNode 的条件引脚 - var returnPin = callNode.OutputPins.FirstOrDefault(p => p.Name == "Return"); - var targetPin = targetNode.InputPins.FirstOrDefault(p => p.Name != "Exec"); - if (returnPin != null && targetPin != null) - { - context.Blueprint.AddConnection(new BlueprintConnection - { - SourceNodeId = callNode.Id, - SourcePinId = returnPin.Id, - TargetNodeId = targetNode.Id, - TargetPinId = targetPin.Id, - PubVarName = null - }); - } - } - break; - - default: - var exprStr = expr.ToString(); - if (IsVariableReference(exprStr)) - { - CreateDataConnectionForVariable(exprStr, targetNode, context); - } - break; - } - } - - private void CreateConstNodeForLiteral(LiteralExpressionSyntax literal, BlueprintNode targetNode, ConversionContext context) - { - var targetPin = targetNode.InputPins.FirstOrDefault(p => p.Name != "Exec"); - if (targetPin == null) return; - - if (literal.Token.Value is string strVal) - { - // ✅ 不再创建 ConstNode,直接设置预设值 - targetPin.DefaultValue = strVal; - Log.Debug("Set DefaultValue for {TargetNode}.{TargetPin}: {Value}", targetNode.Name, targetPin.Name, strVal); - } - else if (literal.Token.Value is int intVal) - { - // ✅ 不再创建 ConstNode,直接设置预设值 - targetPin.DefaultValue = intVal.ToString(); - Log.Debug("Set DefaultValue for {TargetNode}.{TargetPin}: {Value}", targetNode.Name, targetPin.Name, intVal); - } - } - - /// - /// Sets DefaultValue for a literal and connects it to a specific parameter pin. - /// Used by ProcessArgumentExpression to set literal defaults for exact parameter pins. - /// - private void CreateConstNodeForLiteralWithPin(LiteralExpressionSyntax literal, BlueprintPin targetPin, BlueprintNode targetNode, ConversionContext context) - { - if (literal.Token.Value is string strVal) - { - // ✅ 不再创建 ConstNode,直接设置预设值 - targetPin.DefaultValue = strVal; - Log.Debug("Set DefaultValue for {TargetNode}.{TargetPin}: {Value}", targetNode.Name, targetPin.Name, strVal); - } - else if (literal.Token.Value is int intVal) - { - // ✅ 不再创建 ConstNode,直接设置预设值 - targetPin.DefaultValue = intVal.ToString(); - Log.Debug("Set DefaultValue for {TargetNode}.{TargetPin}: {Value}", targetNode.Name, targetPin.Name, intVal); - } - } - - private void CreateDataConnectionsForArguments(SeparatedSyntaxList? arguments, BlueprintNode callNode, ConversionContext context) - { - if (!arguments.HasValue || arguments.Value.Count == 0) return; - - // NOTE: AddParameterPins names parameters using node.InputPins.Count at the time of creation. - // Since CallNode/CallHelperNode already has Exec (at count=0), the first parameter gets name "param1". - // Therefore paramIndex starts at 1 to match the naming convention. - var paramIndex = 1; - foreach (var arg in arguments.Value) - { - var paramPin = callNode.InputPins.FirstOrDefault(p => p.Name == $"param{paramIndex}"); - if (paramPin == null) break; - paramIndex++; - ProcessArgumentExpression(arg.Expression, paramPin, callNode, context); - } - } - - private void ProcessArgumentExpression(ExpressionSyntax expr, BlueprintPin targetPin, BlueprintNode callNode, ConversionContext context) - { - switch (expr) - { - case InvocationExpressionSyntax invoke: - var funcName = GetMethodNameFromExpression(invoke); - Log.Debug("[ProcessArgumentExpression] InvocationExpressionSyntax: funcName={FuncName}, expr={Expr}", - funcName, expr.ToString()); - - if (funcName == "Get") - { - var varName = invoke.ArgumentList.Arguments.FirstOrDefault()?.Expression.ToString() ?? string.Empty; - - // ✅ Check if varName is a PubVarBlock variable - var isPubVar = context.Script.PubVarBlock?.Variables - .Any(v => v.Name == varName) == true; - - if (isPubVar) - { - // ✅ PubVar: 不创建 GetNode,直接从 VariableSources 获取 - // 查找时使用简单变量名(PubVarNames 中记录的名称) - var source = context.VariableSources.Values - .FirstOrDefault(s => s.PubVarName == varName); - if (source != null) - { - context.Blueprint.AddConnection(new BlueprintConnection - { - SourceNodeId = source.Node.Id, - SourcePinId = source.Pin.Id, - TargetNodeId = callNode.Id, - TargetPinId = targetPin.Id, - PubVarName = varName // ✅ PubVar 变量设置 PubVarName - }); - } - else - { - Log.Warning("[ProcessArgumentExpression] PubVar {VarName} not found in VariableSources", varName); - } - } - else - { - // ✅ ConstBlock variable: 创建独立的 GetNode(不连接到 ConstNode) - var getNode = new GetNode { VarName = varName, Name = $"Get:{varName}" }; - context.Blueprint.AddNode(getNode); - var getValuePin = getNode.OutputPins.First(p => p.Name == "Value"); - - // 连接到目标参数(GetNode 通过 VarName 在运行时获取 ConstBlock 值) - context.Blueprint.AddConnection(new BlueprintConnection - { - SourceNodeId = getNode.Id, - SourcePinId = getValuePin.Id, - TargetNodeId = callNode.Id, - TargetPinId = targetPin.Id, - PubVarName = null // ✅ ConstBlock 变量 - }); - } - } - else if (funcName == "Set") - { - var args = invoke.ArgumentList.Arguments; - if (args.Count >= 2) - { - var varName = args[0].Expression.ToString(); - var valueExpr = args[1].Expression; - var setNode = new SetNode { VarName = varName, Name = $"Set:{varName}" }; - context.Blueprint.AddNode(setNode); - var setValuePin = setNode.InputPins.FirstOrDefault(p => p.Name == "Value"); - if (setValuePin != null) - { - ProcessArgumentExpression(valueExpr, setValuePin, setNode, context); - } - } - } - else - { - var isHelper = context.Script.HelperFunctions.Any(h => h.Name == funcName); - BlueprintNode innerCallNode = isHelper - ? new CallHelperNode { HelperFunctionName = funcName, Name = $"Helper:{funcName}" } - : new CallNode { FunctionName = funcName, Name = $"Call:{funcName}" }; - - AddParameterPins(innerCallNode, invoke.ArgumentList.Arguments); - context.Blueprint.AddNode(innerCallNode); - context.PendingNodesForExecChain.Add(innerCallNode); - CreateDataConnectionsForArguments(invoke.ArgumentList.Arguments, innerCallNode, context); - - var returnPin = innerCallNode.OutputPins.FirstOrDefault(p => p.Name == "Return"); - if (returnPin != null) - { - context.Blueprint.AddConnection(new BlueprintConnection - { - SourceNodeId = innerCallNode.Id, - SourcePinId = returnPin.Id, - TargetNodeId = callNode.Id, - TargetPinId = targetPin.Id, - PubVarName = null - }); - context.VariableSources[$"__call_{funcName}_{Guid.NewGuid():N}__"] = new VariableSource - { - Node = innerCallNode, - Pin = returnPin, - PubVarName = null - }; - } - } - break; - - case LiteralExpressionSyntax literal: - CreateConstNodeForLiteralWithPin(literal, targetPin, callNode, context); - break; - - case IdentifierNameSyntax identifier: - CreateDataConnectionForVariable(identifier.Identifier.Text, targetPin, callNode, context); - break; - - default: - var exprStr = expr.ToString(); - if (IsVariableReference(exprStr)) - { - CreateDataConnectionForVariable(exprStr, targetPin, callNode, context); - } - break; - } - } - - private void CreateDataConnectionForVariable(string varName, BlueprintPin targetPin, BlueprintNode callNode, ConversionContext context) - { - if (context.Script.ConstBlock?.Variables.Any(v => v.Name == varName) == true) - { - // ✅ ConstBlock 变量:直接从 ConstNode 建立连接到目标节点 - var constNode = context.Blueprint.Nodes.OfType().FirstOrDefault(c => c.ConstName == varName); - if (constNode != null) - { - var constPin = constNode.OutputPins.First(p => p.Name == "Value"); - context.Blueprint.AddConnection(new BlueprintConnection - { - SourceNodeId = constNode.Id, - SourcePinId = constPin.Id, - TargetNodeId = callNode.Id, - TargetPinId = targetPin.Id, - PubVarName = null // ✅ ConstBlock 变量不应设置 PubVarName - }); - } - } - else - { - // ✅ PubVar 或其他变量:按 PubVarName 查找 VariableSources - var source = context.VariableSources.Values.FirstOrDefault(s => s.PubVarName == varName); - if (source != null) - { - context.Blueprint.AddConnection(new BlueprintConnection - { - SourceNodeId = source.Node.Id, - SourcePinId = source.Pin.Id, - TargetNodeId = callNode.Id, - TargetPinId = targetPin.Id, - PubVarName = source.PubVarName - }); - } - } - } - - private void CreateDataConnectionForVariable(string varName, BlueprintNode targetNode, ConversionContext context) - { - var targetPin = targetNode.InputPins.FirstOrDefault(p => p.Name != "Exec"); - if (targetPin == null) return; - - if (context.Script.ConstBlock?.Variables.Any(v => v.Name == varName) == true) - { - // ✅ ConstBlock 变量:直接从 ConstNode 建立连接到目标节点 - var constNode = context.Blueprint.Nodes.OfType().FirstOrDefault(c => c.ConstName == varName); - if (constNode != null) - { - var constPin = constNode.OutputPins.First(p => p.Name == "Value"); - context.Blueprint.AddConnection(new BlueprintConnection - { - SourceNodeId = constNode.Id, - SourcePinId = constPin.Id, - TargetNodeId = targetNode.Id, - TargetPinId = targetPin.Id, - PubVarName = null // ✅ ConstBlock 变量不应设置 PubVarName - }); - } - } - else - { - // ✅ PubVar 或其他变量:按 PubVarName 查找 VariableSources - var source = context.VariableSources.Values.FirstOrDefault(s => s.PubVarName == varName); - if (source != null) - { - context.Blueprint.AddConnection(new BlueprintConnection - { - SourceNodeId = source.Node.Id, - SourcePinId = source.Pin.Id, - TargetNodeId = targetNode.Id, - TargetPinId = targetPin.Id, - PubVarName = source.PubVarName - }); - } - } - } - - public class VariableSource - { - public BlueprintNode Node { get; set; } = null!; - public BlueprintPin Pin { get; set; } = null!; - /// 如果是 PubVar,则记录 PubVarName;否则为 null - public string? PubVarName { get; set; } - } - - internal class ConversionContext - { - public required Contract.Workflow.Blueprint Blueprint { get; set; } - public required BlockScript Script { get; set; } - public Dictionary NodeMap { get; set; } = new(); - public Dictionary NamedBlockMap { get; set; } = new(); - public Dictionary BlockFirstNodes { get; set; } = new(); - public EntryNode? EntryNode { get; set; } - public int NextPubVarIndex { get; set; } - public HashSet VisitedBlocks { get; set; } = new(); - public Dictionary VariableSources { get; set; } = new(); - public List DataConnections { get; set; } = new(); - public Dictionary LoopNodesByParentBlock { get; set; } = new(); - public BlueprintNode? LastProcessedNode { get; set; } - /// - /// 当前正在处理的父块名称(用于 Loop 节点复用) - /// - public string? CurrentParentBlock { get; set; } - /// - /// 鍦ㄥ弬鏁板鐞嗕腑鍒涘缓鐨勫祵濂楄皟鐢ㄨ妭鐐癸紝闇€瑕佹坊鍔犲埌鎵ц閾句腑 - /// - public List PendingNodesForExecChain { get; set; } = new(); - } -} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintRenderDataService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintRenderDataService.cs index 33cf1c81..628e7c02 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintRenderDataService.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintRenderDataService.cs @@ -17,46 +17,20 @@ public BlueprintRenderData GetRenderData(Contract.Workflow.Blueprint blueprint) var exec = new List(); var data = new List(); - Log.Debug("[RenderData] === Classifying {ConnCount} connections from blueprint '{BpName}' ===", - blueprint.Connections.Count, blueprint.Name); - foreach (var conn in blueprint.Connections) { var sourceNode = blueprint.GetNodeById(conn.SourceNodeId); - var targetNode = blueprint.GetNodeById(conn.TargetNodeId); var sourcePin = sourceNode?.OutputPins .FirstOrDefault(p => p.Id == conn.SourcePinId); - var targetPin = targetNode?.InputPins - .FirstOrDefault(p => p.Id == conn.TargetPinId); - - var srcPinType = sourcePin?.Type.ToString() ?? "null"; - var tgtPinType = targetPin?.Type.ToString() ?? "null"; - - // [DIAG] Log both classification results for comparison - var classifyBySourceOnly = sourcePin?.Type == PinType.Execution; - var classifyByEither = sourcePin?.Type == PinType.Execution - || targetPin?.Type == PinType.Execution; - if (classifyBySourceOnly) - { + if (sourcePin?.Type == PinType.Execution) exec.Add(conn); - Log.Debug("[RenderData] EXEC {SrcName}.{SrcPin} [{SrcType}] -> {TgtName}.{TgtPin} [{TgtType}] (bySource={BySrc}, byEither={ByEither})", - sourceNode?.Name ?? "NULL", sourcePin?.Name ?? "?", - targetNode?.Name ?? "NULL", targetPin?.Name ?? "?", - srcPinType, tgtPinType, classifyBySourceOnly, classifyByEither); - } else - { data.Add(conn); - Log.Debug("[RenderData] DATA {SrcName}.{SrcPin} [{SrcType}] -> {TgtName}.{TgtPin} [{TgtType}] (bySource={BySrc}, byEither={ByEither})", - sourceNode?.Name ?? "NULL", sourcePin?.Name ?? "?", - targetNode?.Name ?? "NULL", targetPin?.Name ?? "?", - srcPinType, tgtPinType, classifyBySourceOnly, classifyByEither); - } } - Log.Debug("[RenderData] === Result: {ExecCount} exec, {DataCount} data (total {Total}) ===", - exec.Count, data.Count, exec.Count + data.Count); + Log.Debug("[RenderData] Classified {Total} connections: {ExecCount} exec, {DataCount} data", + blueprint.Connections.Count, exec.Count, data.Count); return new BlueprintRenderData { diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintService.cs index 8c0dfb80..74fc8038 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintService.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintService.cs @@ -12,21 +12,18 @@ namespace KitX.Core.Workflow.Blueprint; /// public class BlueprintService : IBlueprintService { - private readonly IBlockScriptParser _parser; + private readonly IBlockScriptToBlueprintConverter _toBlueprintConverter; + private readonly IBlueprintToBlockScriptConverter _toBlockScriptConverter; private readonly IBlockScriptExecutor _executor; - private readonly INodeCreationService _nodeFactory; - private readonly ILayoutService _layoutService; public BlueprintService( - IBlockScriptParser parser, - IBlockScriptExecutor executor, - INodeCreationService nodeFactory, - ILayoutService layoutService) + IBlockScriptToBlueprintConverter toBlueprintConverter, + IBlueprintToBlockScriptConverter toBlockScriptConverter, + IBlockScriptExecutor executor) { - _parser = parser; + _toBlueprintConverter = toBlueprintConverter; + _toBlockScriptConverter = toBlockScriptConverter; _executor = executor; - _nodeFactory = nodeFactory; - _layoutService = layoutService; } public Contract.Workflow.Blueprint CreateBlueprint() @@ -45,11 +42,7 @@ public Contract.Workflow.Blueprint CreateBlueprint() try { Log.Information("Importing Blueprint from BlockScript"); - - var converter = new BlockScriptToBlueprintConverter( - _parser, _nodeFactory, _layoutService); - var blueprint = converter.Convert(sourceCode, helperFunctions); - + var blueprint = _toBlueprintConverter.Convert(sourceCode, helperFunctions); blueprint.ModifiedAt = DateTime.Now; return blueprint; } @@ -65,8 +58,7 @@ public string ExportToBlockScript(Contract.Workflow.Blueprint blueprint) try { Log.Information("Exporting Blueprint to BlockScript"); - var converter = new BlueprintToBlockScriptConverter(); - return converter.Convert(blueprint); + return _toBlockScriptConverter.Convert(blueprint); } catch (Exception ex) { @@ -80,8 +72,7 @@ public async Task ExecuteBlueprintAsync(Contract.Wor try { Log.Information("Executing Blueprint"); - var converter = new BlueprintToBlockScriptConverter(); - var blockScript = converter.ConvertToBlockScript(blueprint); + var blockScript = _toBlockScriptConverter.ConvertToBlockScript(blueprint); var result = await _executor.ExecuteAsync(blockScript, null, CancellationToken.None); return result; } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintToBlockScriptConverter.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintToBlockScriptConverter.cs index 1f695b7e..85a5ca9a 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintToBlockScriptConverter.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintToBlockScriptConverter.cs @@ -187,106 +187,51 @@ private void ProcessSubGraphs(Contract.Workflow.Blueprint blueprint, ReverseConv private void ProcessBranchSubGraphs(Contract.Workflow.Blueprint blueprint, BranchNode branch, ReverseConversionContext context, HashSet processedTargets) { - var truePin = branch.OutputPins.FirstOrDefault(p => p.Name == "True"); - var falsePin = branch.OutputPins.FirstOrDefault(p => p.Name == "False"); - - // True branch - if (truePin != null) - { - var trueConn = blueprint.Connections.FirstOrDefault(c => c.SourcePinId == truePin.Id); - if (trueConn != null && !processedTargets.Contains(trueConn.TargetNodeId)) - { - var targetNode = blueprint.GetNodeById(trueConn.TargetNodeId); - if (targetNode != null) - { - var blockName = $"Block_{context.BlockIndex++}"; - var block = CollectSubGraph(blueprint, targetNode, context, processedTargets); - context.Script.NamedBlocks[blockName] = block; - processedTargets.Add(trueConn.TargetNodeId); - - // Set the target block name on the corresponding FlowControlStatement - if (context.ControlFlowMap.TryGetValue(branch.Id, out var flow)) - { - flow.TrueBlockName = blockName; - } - } - } - } - - // False branch - if (falsePin != null) - { - var falseConn = blueprint.Connections.FirstOrDefault(c => c.SourcePinId == falsePin.Id); - if (falseConn != null && !processedTargets.Contains(falseConn.TargetNodeId)) - { - var targetNode = blueprint.GetNodeById(falseConn.TargetNodeId); - if (targetNode != null) - { - var blockName = $"Block_{context.BlockIndex++}"; - var block = CollectSubGraph(blueprint, targetNode, context, processedTargets); - context.Script.NamedBlocks[blockName] = block; - processedTargets.Add(falseConn.TargetNodeId); - - // Set the target block name on the corresponding FlowControlStatement - if (context.ControlFlowMap.TryGetValue(branch.Id, out var flow)) - { - flow.FalseBlockName = blockName; - } - } - } - } + ProcessOutputArm(blueprint, branch, "True", context, processedTargets, null, + (flow, blockName) => flow.TrueBlockName = blockName); + ProcessOutputArm(blueprint, branch, "False", context, processedTargets, null, + (flow, blockName) => flow.FalseBlockName = blockName); } private void ProcessLoopSubGraphs(Contract.Workflow.Blueprint blueprint, LoopNode loop, ReverseConversionContext context, HashSet processedTargets) { - var loopBodyPin = loop.OutputPins.FirstOrDefault(p => p.Name == "LoopBody"); - var loopEndPin = loop.OutputPins.FirstOrDefault(p => p.Name == "LoopEnd"); + ProcessOutputArm(blueprint, loop, "LoopBody", context, processedTargets, loop.Id, + (flow, blockName) => flow.TrueBlockName = blockName); + ProcessOutputArm(blueprint, loop, "LoopEnd", context, processedTargets, null, + (flow, blockName) => flow.FalseBlockName = blockName); + } - // Loop body - if (loopBodyPin != null) - { - var bodyConn = blueprint.Connections.FirstOrDefault(c => c.SourcePinId == loopBodyPin.Id); - if (bodyConn != null && !processedTargets.Contains(bodyConn.TargetNodeId)) - { - var targetNode = blueprint.GetNodeById(bodyConn.TargetNodeId); - if (targetNode != null) - { - var blockName = $"Block_{context.BlockIndex++}"; - var block = CollectSubGraph(blueprint, targetNode, context, processedTargets, loop.Id); - context.Script.NamedBlocks[blockName] = block; - processedTargets.Add(bodyConn.TargetNodeId); + /// + /// Processes a single output arm (True/False for Branch, LoopBody/LoopEnd for Loop). + /// Extracted to eliminate duplication between Branch and Loop sub-graph processing. + /// + private void ProcessOutputArm( + Contract.Workflow.Blueprint blueprint, + BlueprintNode controlNode, + string outputPinName, + ReverseConversionContext context, + HashSet processedTargets, + string? loopbackTargetId, + Action setBlockName) + { + var pin = controlNode.OutputPins.FirstOrDefault(p => p.Name == outputPinName); + if (pin == null) return; - // Set the target block name on the corresponding FlowControlStatement - if (context.ControlFlowMap.TryGetValue(loop.Id, out var flow)) - { - flow.TrueBlockName = blockName; - } - } - } - } + var conn = blueprint.Connections.FirstOrDefault(c => c.SourcePinId == pin.Id); + if (conn == null || processedTargets.Contains(conn.TargetNodeId)) return; - // Loop end (after loop) - if (loopEndPin != null) - { - var endConn = blueprint.Connections.FirstOrDefault(c => c.SourcePinId == loopEndPin.Id); - if (endConn != null && !processedTargets.Contains(endConn.TargetNodeId)) - { - var targetNode = blueprint.GetNodeById(endConn.TargetNodeId); - if (targetNode != null) - { - var blockName = $"Block_{context.BlockIndex++}"; - var block = CollectSubGraph(blueprint, targetNode, context, processedTargets); - context.Script.NamedBlocks[blockName] = block; - processedTargets.Add(endConn.TargetNodeId); + var targetNode = blueprint.GetNodeById(conn.TargetNodeId); + if (targetNode == null) return; - // Set the target block name on the corresponding FlowControlStatement - if (context.ControlFlowMap.TryGetValue(loop.Id, out var flow)) - { - flow.FalseBlockName = blockName; - } - } - } + var blockName = $"Block_{context.BlockIndex++}"; + var block = CollectSubGraph(blueprint, targetNode, context, processedTargets, loopbackTargetId); + context.Script.NamedBlocks[blockName] = block; + processedTargets.Add(conn.TargetNodeId); + + if (context.ControlFlowMap.TryGetValue(controlNode.Id, out var flow)) + { + setBlockName(flow, blockName); } } @@ -429,24 +374,7 @@ private void CollectNodesRecursive(Contract.Workflow.Blueprint blueprint, Bluepr } private string GetConditionExpression(BlueprintNode node, Contract.Workflow.Blueprint blueprint) - { - var condPin = node.InputPins.FirstOrDefault(p => p.Name == "Condition"); - if (condPin == null) return string.Empty; - - // Find the data connection to the Condition pin - var dataConn = blueprint.Connections - .FirstOrDefault(c => c.TargetPinId == condPin.Id); - - if (dataConn == null) - return condPin.DefaultValue ?? string.Empty; - - var sourceNode = blueprint.GetNodeById(dataConn.SourceNodeId); - if (sourceNode is ConstNode constNode) - return constNode.ConstName; - - // For other nodes, return the PubVar name if available - return dataConn.PubVarName ?? condPin.DefaultValue ?? string.Empty; - } + => GetInputValue(node, "Condition", blueprint); private string GetInputValue(BlueprintNode node, string pinName, Contract.Workflow.Blueprint blueprint) { diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ConnectionCreationService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ConnectionCreationService.cs deleted file mode 100644 index a0409255..00000000 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ConnectionCreationService.cs +++ /dev/null @@ -1,399 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; -using KitX.Core.Contract.Workflow; -using Serilog; - -namespace KitX.Core.Workflow.Blueprint; - -public class ConnectionCreationService : IConnectionCreationService -{ - public void CreateExecConnection(BlueprintNode source, BlueprintNode target, Contract.Workflow.Blueprint blueprint) - { - var sourceExecOut = source.OutputPins.FirstOrDefault(p => p.Name == "Exec"); - var targetExecIn = target.InputPins.FirstOrDefault(p => p.Name == "Exec"); - if (sourceExecOut == null || targetExecIn == null) { Log.Warning("Cannot create Exec connection: missing Exec pin"); return; } - if (sourceExecOut.Direction != PinDirection.Output) { Log.Warning("Cannot create Exec connection: source.Exec is not an output pin"); return; } - if (source.Id == target.Id) { Log.Warning("Skipping self-loop Exec connection"); return; } - blueprint.AddConnection(new BlueprintConnection { SourceNodeId = source.Id, SourcePinId = sourceExecOut.Id, TargetNodeId = target.Id, TargetPinId = targetExecIn.Id }); - } - - public void CreateDataConnection(BlueprintNode source, string sourcePin, BlueprintNode target, string targetPin, string? pubVarName, Contract.Workflow.Blueprint blueprint) - { - var srcPin = source.OutputPins.FirstOrDefault(p => p.Name == sourcePin); - var tgtPin = target.InputPins.FirstOrDefault(p => p.Name == targetPin); - if (srcPin == null || tgtPin == null) { Log.Warning("Cannot create data connection: missing pin"); return; } - blueprint.AddConnection(new BlueprintConnection { SourceNodeId = source.Id, SourcePinId = srcPin.Id, TargetNodeId = target.Id, TargetPinId = tgtPin.Id, PubVarName = pubVarName }); - } - - public void LinkNodesWithExec(List nodes, Contract.Workflow.Blueprint blueprint) - { - for (int i = 0; i < nodes.Count - 1; i++) - { - var currentNode = nodes[i]; - var nextNode = nodes[i + 1]; - if (currentNode.Id == nextNode.Id) continue; - var currentExecOut = currentNode.OutputPins.FirstOrDefault(p => p.Name == "Exec"); - if (currentExecOut == null) { Log.Debug("Skipping Exec link"); continue; } - if (currentExecOut.Direction != PinDirection.Output) { Log.Warning("Cannot create Exec connection"); continue; } - var nextExecIn = nextNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); - if (nextExecIn == null) { Log.Warning("Cannot create Exec connection"); continue; } - blueprint.AddConnection(new BlueprintConnection { SourceNodeId = currentNode.Id, SourcePinId = currentExecOut.Id, TargetNodeId = nextNode.Id, TargetPinId = nextExecIn.Id }); - } - } - - - public void CreateDataConnectionsForExpression(string expression, BlueprintNode targetNode, ConversionContext context) - { - var wrappedCode = "_ = " + expression + ";"; - var syntaxTree = CSharpSyntaxTree.ParseText(wrappedCode, cancellationToken: CancellationToken.None); - var root = syntaxTree.GetCompilationUnitRoot(); - var globalStmt = root.Members.FirstOrDefault() as GlobalStatementSyntax; - var stmt = globalStmt?.Statement as ExpressionStatementSyntax; - - if (stmt?.Expression == null) return; - - ProcessExpressionForDataConnections(stmt.Expression, targetNode, context); - } - - private void ProcessExpressionForDataConnections(ExpressionSyntax expr, BlueprintNode targetNode, ConversionContext context) - { - switch (expr) - { - case LiteralExpressionSyntax literal: - CreateConstNodeForLiteral(literal, targetNode, context); - break; - - case IdentifierNameSyntax identifier: - CreateDataConnectionForVariable(identifier.Identifier.Text, targetNode, context); - break; - - case AssignmentExpressionSyntax assignment: - ProcessExpressionForDataConnections(assignment.Right, targetNode, context); - break; - - case InvocationExpressionSyntax invoke: - var funcName = GetMethodNameFromExpression(invoke); - - if (funcName != "Get" && funcName != "Set") - { - var isHelper = context.Script.HelperFunctions.Any(h => h.Name == funcName); - BlueprintNode callNode; - if (isHelper) - callNode = new CallHelperNode { HelperFunctionName = funcName, Name = "Helper:" + funcName }; - else - callNode = new CallNode { FunctionName = funcName, Name = "Call:" + funcName }; - - AddParameterPins(callNode, invoke.ArgumentList.Arguments); - context.Blueprint.AddNode(callNode); - context.PendingNodesForExecChain.Add(callNode); - - CreateDataConnectionsForArguments(invoke.ArgumentList.Arguments, callNode, context); - - var returnPin = callNode.OutputPins.FirstOrDefault(p => p.Name == "Return"); - var targetPin = targetNode.InputPins.FirstOrDefault(p => p.Name != "Exec"); - if (returnPin != null && targetPin != null) - { - context.Blueprint.AddConnection(new BlueprintConnection - { - SourceNodeId = callNode.Id, - SourcePinId = returnPin.Id, - TargetNodeId = targetNode.Id, - TargetPinId = targetPin.Id, - PubVarName = null - }); - } - } - break; - - default: - var exprStr = expr.ToString(); - if (IsVariableReference(exprStr)) - { - CreateDataConnectionForVariable(exprStr, targetNode, context); - } - break; - } - } - - - public void CreateDataConnectionsForArguments(SeparatedSyntaxList? arguments, BlueprintNode callNode, ConversionContext context) - { - if (!arguments.HasValue || arguments.Value.Count == 0) return; - - var paramIndex = 1; - foreach (var arg in arguments.Value) - { - var paramPin = callNode.InputPins.FirstOrDefault(p => p.Name == "param" + paramIndex); - if (paramPin == null) break; - paramIndex++; - ProcessArgumentExpression(arg.Expression, paramPin, callNode, context); - } - } - - public void AddParameterPins(BlueprintNode node, SeparatedSyntaxList? arguments) - { - if (!arguments.HasValue || arguments.Value.Count == 0) return; - - foreach (var arg in arguments.Value) - { - node.InputPins.Add(new BlueprintPin - { - Name = "param" + node.InputPins.Count, - Direction = PinDirection.Input, - Type = PinType.Any - }); - } - } - - public void CreateDataConnectionForVariable(string varName, BlueprintPin targetPin, BlueprintNode callNode, ConversionContext context) - { - if (context.Script.ConstBlock?.Variables.Any(v => v.Name == varName) == true) - { - var constNode = context.Blueprint.Nodes.OfType().FirstOrDefault(c => c.ConstName == varName); - if (constNode != null) - { - var constPin = constNode.OutputPins.First(p => p.Name == "Value"); - context.Blueprint.AddConnection(new BlueprintConnection - { - SourceNodeId = constNode.Id, - SourcePinId = constPin.Id, - TargetNodeId = callNode.Id, - TargetPinId = targetPin.Id, - PubVarName = null - }); - } - } - else - { - var source = context.VariableSources.Values.FirstOrDefault(s => s.PubVarName == varName); - if (source != null) - { - context.Blueprint.AddConnection(new BlueprintConnection - { - SourceNodeId = source.Node.Id, - SourcePinId = source.Pin.Id, - TargetNodeId = callNode.Id, - TargetPinId = targetPin.Id, - PubVarName = source.PubVarName - }); - } - } - } - - public void CreateDataConnectionForVariable(string varName, BlueprintNode targetNode, ConversionContext context) - { - var targetPin = targetNode.InputPins.FirstOrDefault(p => p.Name != "Exec"); - if (targetPin == null) return; - - if (context.Script.ConstBlock?.Variables.Any(v => v.Name == varName) == true) - { - var constNode = context.Blueprint.Nodes.OfType().FirstOrDefault(c => c.ConstName == varName); - if (constNode != null) - { - var constPin = constNode.OutputPins.First(p => p.Name == "Value"); - context.Blueprint.AddConnection(new BlueprintConnection - { - SourceNodeId = constNode.Id, - SourcePinId = constPin.Id, - TargetNodeId = targetNode.Id, - TargetPinId = targetPin.Id, - PubVarName = null - }); - } - } - else - { - var source = context.VariableSources.Values.FirstOrDefault(s => s.PubVarName == varName); - if (source != null) - { - context.Blueprint.AddConnection(new BlueprintConnection - { - SourceNodeId = source.Node.Id, - SourcePinId = source.Pin.Id, - TargetNodeId = targetNode.Id, - TargetPinId = targetPin.Id, - PubVarName = source.PubVarName - }); - } - } - } - - public void ProcessArgumentExpression(ExpressionSyntax expr, BlueprintPin targetPin, BlueprintNode callNode, ConversionContext context) - { - switch (expr) - { - case InvocationExpressionSyntax invoke: - var funcName = GetMethodNameFromExpression(invoke); - Log.Debug("[ProcessArgumentExpression] InvocationExpressionSyntax: funcName={FuncName}, expr={Expr}", - funcName, expr.ToString()); - - if (funcName == "Get") - { - var varName = invoke.ArgumentList.Arguments.FirstOrDefault()?.Expression.ToString() ?? string.Empty; - - var isPubVar = context.Script.PubVarBlock?.Variables - .Any(v => v.Name == varName) == true; - - if (isPubVar) - { - var source = context.VariableSources.Values - .FirstOrDefault(s => s.PubVarName == varName); - if (source != null) - { - context.Blueprint.AddConnection(new BlueprintConnection - { - SourceNodeId = source.Node.Id, - SourcePinId = source.Pin.Id, - TargetNodeId = callNode.Id, - TargetPinId = targetPin.Id, - PubVarName = varName - }); - } - else - { - Log.Warning("[ProcessArgumentExpression] PubVar {VarName} not found in VariableSources", varName); - } - } - else - { - var getNode = new GetNode { VarName = varName, Name = "Get:" + varName }; - context.Blueprint.AddNode(getNode); - context.PendingNodesForExecChain.Add(getNode); - var getValuePin = getNode.OutputPins.First(p => p.Name == "Value"); - - context.Blueprint.AddConnection(new BlueprintConnection - { - SourceNodeId = getNode.Id, - SourcePinId = getValuePin.Id, - TargetNodeId = callNode.Id, - TargetPinId = targetPin.Id, - PubVarName = null - }); - } - } - else if (funcName == "Set") - { - var args = invoke.ArgumentList.Arguments; - if (args.Count >= 2) - { - var varName = args[0].Expression.ToString(); - var valueExpr = args[1].Expression; - var setNode = new SetNode { VarName = varName, Name = "Set:" + varName }; - context.Blueprint.AddNode(setNode); - var setValuePin = setNode.InputPins.FirstOrDefault(p => p.Name == "Value"); - if (setValuePin != null) - { - ProcessArgumentExpression(valueExpr, setValuePin, setNode, context); - } - } - } - else - { - var isHelper = context.Script.HelperFunctions.Any(h => h.Name == funcName); - BlueprintNode innerCallNode = isHelper - ? new CallHelperNode { HelperFunctionName = funcName, Name = "Helper:" + funcName } - : new CallNode { FunctionName = funcName, Name = "Call:" + funcName }; - - AddParameterPins(innerCallNode, invoke.ArgumentList.Arguments); - context.Blueprint.AddNode(innerCallNode); - context.PendingNodesForExecChain.Add(innerCallNode); - CreateDataConnectionsForArguments(invoke.ArgumentList.Arguments, innerCallNode, context); - - var returnPin = innerCallNode.OutputPins.FirstOrDefault(p => p.Name == "Return"); - if (returnPin != null) - { - context.Blueprint.AddConnection(new BlueprintConnection - { - SourceNodeId = innerCallNode.Id, - SourcePinId = returnPin.Id, - TargetNodeId = callNode.Id, - TargetPinId = targetPin.Id, - PubVarName = null - }); - context.VariableSources["__call_" + funcName + "_" + Guid.NewGuid().ToString("N") + "__"] = new VariableSource - { - Node = innerCallNode, - Pin = returnPin, - PubVarName = null - }; - } - } - break; - - case LiteralExpressionSyntax literal: - CreateConstNodeForLiteralWithPin(literal, targetPin, callNode, context); - break; - - case IdentifierNameSyntax identifier: - CreateDataConnectionForVariable(identifier.Identifier.Text, targetPin, callNode, context); - break; - - default: - var exprStr = expr.ToString(); - if (IsVariableReference(exprStr)) - { - CreateDataConnectionForVariable(exprStr, targetPin, callNode, context); - } - break; - } - } - - public void CreateConstNodeForLiteral(LiteralExpressionSyntax literal, BlueprintNode targetNode, ConversionContext context) - { - var targetPin = targetNode.InputPins.FirstOrDefault(p => p.Name != "Exec"); - if (targetPin == null) return; - - if (literal.Token.Value is string strVal) - { - targetPin.DefaultValue = strVal; - Log.Debug("Set DefaultValue for {TargetNode}.{TargetPin}: {Value}", targetNode.Name, targetPin.Name, strVal); - } - else if (literal.Token.Value is int intVal) - { - targetPin.DefaultValue = intVal.ToString(); - Log.Debug("Set DefaultValue for {TargetNode}.{TargetPin}: {Value}", targetNode.Name, targetPin.Name, intVal); - } - } - - public void CreateConstNodeForLiteralWithPin(LiteralExpressionSyntax literal, BlueprintPin targetPin, BlueprintNode targetNode, ConversionContext context) - { - if (literal.Token.Value is string strVal) - { - targetPin.DefaultValue = strVal; - Log.Debug("Set DefaultValue for {TargetNode}.{TargetPin}: {Value}", targetNode.Name, targetPin.Name, strVal); - } - else if (literal.Token.Value is int intVal) - { - targetPin.DefaultValue = intVal.ToString(); - Log.Debug("Set DefaultValue for {TargetNode}.{TargetPin}: {Value}", targetNode.Name, targetPin.Name, intVal); - } - } - - public bool IsVariableReference(string token) - { - if (string.IsNullOrWhiteSpace(token)) return false; - if (bool.TryParse(token, out _)) return false; - if (int.TryParse(token, out _) || double.TryParse(token, out _)) return false; - if (token.StartsWith("'") && token.EndsWith("'")) return false; - if (token.StartsWith("'") && token.EndsWith("'")) return false; - if (token.Contains("(")) return false; - var regex = new System.Text.RegularExpressions.Regex(@"^[a-zA-Z_]\w*$"); - return regex.IsMatch(token); - } - - public string GetMethodNameFromExpression(InvocationExpressionSyntax invoke) - { - if (invoke.Expression is IdentifierNameSyntax id) - return id.Identifier.Text; - if (invoke.Expression is GenericNameSyntax generic) - return generic.Identifier.Text; - if (invoke.Expression is MemberAccessExpressionSyntax member) - return member.Name.Identifier.Text; - return string.Empty; - } -} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ConversionContext.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ConversionContext.cs deleted file mode 100644 index c48f9426..00000000 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ConversionContext.cs +++ /dev/null @@ -1 +0,0 @@ -using System; using System.Collections.Generic; using KitX.Core.Contract.Workflow; namespace KitX.Core.Workflow.Blueprint { public class VariableSource { public BlueprintNode Node { get; set; } = null!; public BlueprintPin Pin { get; set; } = null!; public string? PubVarName { get; set; } } public class ConversionContext { public required Contract.Workflow.Blueprint Blueprint { get; set; } public required BlockScript Script { get; set; } public Dictionary NodeMap { get; set; } = new(); public Dictionary NamedBlockMap { get; set; } = new(); public Dictionary BlockFirstNodes { get; set; } = new(); public EntryNode? EntryNode { get; set; } public int NextPubVarIndex { get; set; } public HashSet VisitedBlocks { get; set; } = new(); public Dictionary VariableSources { get; set; } = new(); public List DataConnections { get; set; } = new(); public Dictionary LoopNodesByParentBlock { get; set; } = new(); public BlueprintNode? LastProcessedNode { get; set; } public string? CurrentParentBlock { get; set; } public List PendingNodesForExecChain { get; set; } = new(); } } \ No newline at end of file diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/FlowProcessingService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/FlowProcessingService.cs deleted file mode 100644 index 948736f0..00000000 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/FlowProcessingService.cs +++ /dev/null @@ -1,954 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; -using KitX.Core.Contract.Workflow; -using Serilog; - -namespace KitX.Core.Workflow.Blueprint; - -public class FlowProcessingService : IFlowProcessingService -{ - private readonly IConnectionCreationService _connectionCreationService; - - public FlowProcessingService(IConnectionCreationService connectionCreationService) - { - _connectionCreationService = connectionCreationService; - } - - public void ProcessConstBlock(BlockDefinition block, ConversionContext context) - { - foreach (var variable in block.Variables) - { - var constValue = variable.DefaultValue?.ToString(); - if (string.IsNullOrEmpty(constValue)) - { - constValue = variable.Type.ToLower() switch - { - "int" or "integer" or "long" or "short" or "byte" => "0", - "float" or "double" or "decimal" => "0", - "bool" or "boolean" => "false", - "string" => "", - "char" => "\0", - _ => "" - }; - } - - var constNode = new ConstNode - { - ConstName = variable.Name, - ConstType = variable.Type, - ConstValue = constValue - }; - - // Set Value pin PinType based on ConstType for correct data edge coloring - var valuePin = constNode.OutputPins.FirstOrDefault(p => p.Name == "Value"); - if (valuePin != null) - { - valuePin.Type = variable.Type.ToLower() switch - { - "int" or "integer" or "long" or "short" or "byte" => PinType.Integer, - "float" or "double" or "decimal" => PinType.Double, - "bool" or "boolean" => PinType.Boolean, - "string" or "char" => PinType.String, - _ => PinType.Any - }; - } - - context.Blueprint.AddNode(constNode); - context.Blueprint.ConstValues.Add(new VariableConstant - { - Name = variable.Name, - Type = variable.Type, - DefaultValue = variable.DefaultValue - }); - Log.Debug("Created ConstNode: Name={ConstName}, Id={NodeId}", constNode.Name, constNode.Id); - } - } - - public void ProcessPubVarBlock(BlockDefinition block, ConversionContext context) - { - foreach (var variable in block.Variables) - { - context.Blueprint.PubVarNames.Add(variable.Name); - } - } - - - public void ProcessMainBlock(BlockDefinition block, ConversionContext context, INodeCreationService nodeCreationService) - { - var entryNode = new EntryNode - { - X = 50, - Y = 50 - }; - context.Blueprint.AddNode(entryNode); - context.EntryNode = entryNode; - Log.Debug("Created EntryNode: Name={NodeName}, Id={NodeId}", entryNode.Name, entryNode.Id); - - var mainFlowNodes = new List(); - var currentY = 150.0; - - for (int i = 0; i < block.Statements.Count; i++) - { - var statement = block.Statements[i]; - var node = ProcessStatement(statement, context, nodeCreationService, 50, currentY); - if (node != null) - { - context.Blueprint.AddNode(node); - mainFlowNodes.Add(node); - currentY += 120; - } - } - - _connectionCreationService.LinkNodesWithExec(mainFlowNodes, context.Blueprint); - - var flowNode = mainFlowNodes.LastOrDefault(n => n is BranchNode || n is LoopNode); - FlowControlStatement? flowCtrl = null; - - if (flowNode == null) - { - if (context.Script.LoopBlocks.TryGetValue("MainBlock", out var loopBlock)) - { - flowCtrl = loopBlock.Statements.FirstOrDefault(s => s is FlowControlStatement) as FlowControlStatement; - if (flowCtrl != null) - { - context.CurrentParentBlock = "MainBlock"; - var loopNodeInLoopBlock = ProcessFlowControlStatement(flowCtrl, context, nodeCreationService, 350, 150); - if (loopNodeInLoopBlock is LoopNode loopFromBlock) - { - flowNode = loopFromBlock; - context.LoopNodesByParentBlock["MainBlock"] = loopFromBlock; - } - } - } - } - else - { - flowCtrl = block.Statements.LastOrDefault(s => s is FlowControlStatement) as FlowControlStatement; - } - - if (flowNode != null && flowCtrl != null) - { - if (flowNode is BranchNode branch) - { - ProcessBranchNodeConnectionsInMain(branch, flowCtrl, context); - } - else if (flowNode is LoopNode loop) - { - ProcessLoopNodeConnectionsInMain(loop, flowCtrl, context); - } - } - - if (mainFlowNodes.Count > 0) - { - var firstNode = mainFlowNodes[0]; - var entryExecPin = entryNode.OutputPins.First(p => p.Name == "Exec"); - var firstExecPin = firstNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); - if (entryExecPin != null && firstExecPin != null) - { - context.Blueprint.AddConnection(new BlueprintConnection - { - SourceNodeId = entryNode.Id, - SourcePinId = entryExecPin.Id, - TargetNodeId = firstNode.Id, - TargetPinId = firstExecPin.Id - }); - } - } - } - - - public BlueprintNode? ProcessStatement(BlockStatement statement, ConversionContext context, INodeCreationService nodeCreationService, double x, double y) - { - switch (statement) - { - case FlowControlStatement flowCtrl: - if ((flowCtrl.ControlType == FlowControlType.Branch || flowCtrl.ControlType == FlowControlType.Loop) && - !string.IsNullOrEmpty(flowCtrl.TrueBlockName) && - !string.IsNullOrEmpty(flowCtrl.FalseBlockName)) - { - if (flowCtrl.ControlType == FlowControlType.Loop && - context.CurrentParentBlock != null && - context.LoopNodesByParentBlock.TryGetValue(context.CurrentParentBlock, out var existingLoop)) - { - context.LastProcessedNode = existingLoop; - return existingLoop; - } - - var flowNode = ProcessFlowControlStatement(flowCtrl, context, nodeCreationService, x, y); - if (flowNode != null) - { - context.LastProcessedNode = flowNode; - } - return flowNode; - } - if (flowCtrl.ControlType == FlowControlType.LoopBodyEnd) - { - HandleLoopBodyEnd(flowCtrl, context); - return null; - } - var otherFlowNode = ProcessFlowControlStatement(flowCtrl, context, nodeCreationService, x, y); - if (otherFlowNode != null) - { - context.LastProcessedNode = otherFlowNode; - } - return otherFlowNode; - - case ExpressionStatement expr: - if (expr.Expression.StartsWith("NextBlock = ")) - return null; - var actionNode = CreateActionNodeFromExpression(expr.Expression, context); - if (actionNode != null) - { - context.LastProcessedNode = actionNode; - } - return actionNode; - - default: - return null; - } - } - - public BlueprintNode? ProcessFlowControlStatement(FlowControlStatement flowCtrl, ConversionContext context, INodeCreationService nodeCreationService, double x, double y) - { - var node = CreateControlFlowNode(flowCtrl); - if (node == null) return null; - - node.X = x; - node.Y = y; - - if (!string.IsNullOrEmpty(flowCtrl.ConditionExpression)) - { - _connectionCreationService.CreateDataConnectionsForExpression(flowCtrl.ConditionExpression, node, context); - } - - return node; - } - - - public BlueprintNode? ProcessBlockRecursive(BlockDefinition block, ConversionContext context, INodeCreationService nodeCreationService, double x, double y) - { - if (context.VisitedBlocks.Contains(block.Name)) - { - return context.BlockFirstNodes.GetValueOrDefault(block.Name); - } - context.VisitedBlocks.Add(block.Name); - - var nodes = new List(); - var currentY = y; - - foreach (var statement in block.Statements) - { - var node = ProcessStatement(statement, context, nodeCreationService, x, currentY); - if (node != null) - { - if (!context.Blueprint.Nodes.Any(n => n.Id == node.Id)) - { - context.Blueprint.AddNode(node); - } - nodes.Add(node); - currentY += 120; - - if (context.PendingNodesForExecChain.Count > 0) - { - var reversed = Enumerable.Reverse(context.PendingNodesForExecChain).ToList(); - var insertIndex = nodes.Count - 1; - foreach (var pendingNode in reversed) - { - if (!nodes.Any(n => n.Id == pendingNode.Id)) - { - nodes.Insert(insertIndex, pendingNode); - insertIndex++; - } - } - context.PendingNodesForExecChain.Clear(); - } - } - } - - if (nodes.Count == 0 && block.Statements.Count > 0) - { - var firstStatement = block.Statements[0]; - if (firstStatement is FlowControlStatement flowCtrl && - (flowCtrl.ControlType == FlowControlType.Branch || flowCtrl.ControlType == FlowControlType.Loop)) - { - var routingNode = ProcessFlowControlStatement(flowCtrl, context, nodeCreationService, x, y); - if (routingNode != null) - { - context.Blueprint.AddNode(routingNode); - nodes.Add(routingNode); - } - } - } - - _connectionCreationService.LinkNodesWithExec(nodes, context.Blueprint); - - context.VisitedBlocks.Add(block.Name); - - var flowNode = nodes.LastOrDefault(n => n is BranchNode || n is LoopNode); - if (flowNode != null) - { - if (flowNode is BranchNode branch) - { - ProcessBranchNodeConnections(branch, block, context, x, y); - } - else if (flowNode is LoopNode loop) - { - ProcessLoopNodeConnections(loop, block, context, x, y); - } - } - - if (!string.IsNullOrEmpty(block.NextBlockName) && - block.Type != BlockType.LoopBlock) - { - var nextBlock = context.Script.GetBlockByName(block.NextBlockName); - if (nextBlock != null && !context.VisitedBlocks.Contains(nextBlock.Name)) - { - if (nodes.Count > 0) - { - var lastNode = nodes.Last(); - var lastExecOut = lastNode.OutputPins.FirstOrDefault(p => p.Name == "Exec"); - - // Skip cross-block connection if lastNode's Exec output already has - // an external connection (e.g. from HandleLoopBodyEnd or branch/loop routing). - // This prevents duplicate connections like BLE → Print(猜大了) - // when the next block is also reachable via Branch True/False. - var lastExecAlreadyConnected = lastExecOut != null && - context.Blueprint.Connections.Any(c => - c.SourceNodeId == lastNode.Id && - c.SourcePinId == lastExecOut.Id && - c.TargetNodeId != lastNode.Id); - - if (!lastExecAlreadyConnected) - { - var nextFirstNode = ProcessBlockRecursive(nextBlock, context, nodeCreationService, x + 300, y); - if (nextFirstNode != null) - { - var nextExecIn = nextFirstNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); - - if (lastExecOut == null || lastExecOut.Direction != PinDirection.Output) - { - Log.Warning("Cannot create cross-block Exec connection: {Node}.Exec is not a valid output pin", - lastNode.Name); - } - else if (lastNode.Id == nextFirstNode.Id) - { - Log.Warning("Skipping self-loop cross-block Exec connection"); - } - else if (lastExecOut != null && nextExecIn != null) - { - context.Blueprint.AddConnection(new BlueprintConnection - { - SourceNodeId = lastNode.Id, - SourcePinId = lastExecOut.Id, - TargetNodeId = nextFirstNode.Id, - TargetPinId = nextExecIn.Id - }); - } - } - } - } - } - } - - var firstNode = nodes.FirstOrDefault(); - if (firstNode != null) - { - context.BlockFirstNodes[block.Name] = firstNode; - } - - return firstNode; - } - - - public void ProcessBranchNodeConnections(BranchNode branch, BlockDefinition block, ConversionContext context, double x, double y) - { - var flowCtrl = block.Statements.FirstOrDefault(s => s is FlowControlStatement) as FlowControlStatement; - if (flowCtrl == null) return; - - double targetY = y + 150; - - if (!string.IsNullOrEmpty(flowCtrl.TrueBlockName)) - { - var trueBlock = context.Script.GetBlockByName(flowCtrl.TrueBlockName); - if (trueBlock != null && !context.VisitedBlocks.Contains(trueBlock.Name)) - { - var firstTrueNode = ProcessBlockRecursive(trueBlock, context, null, x + 300, targetY); - if (firstTrueNode != null && firstTrueNode.Id != branch.Id) - { - var truePin = branch.OutputPins.FirstOrDefault(p => p.Name == "True"); - var firstExecIn = firstTrueNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); - if (truePin != null && firstExecIn != null) - { - context.Blueprint.AddConnection(new BlueprintConnection - { - SourceNodeId = branch.Id, - SourcePinId = truePin.Id, - TargetNodeId = firstTrueNode.Id, - TargetPinId = firstExecIn.Id - }); - } - } - } - } - - if (!string.IsNullOrEmpty(flowCtrl.FalseBlockName)) - { - var falseBlock = context.Script.GetBlockByName(flowCtrl.FalseBlockName); - if (falseBlock != null) - { - BlueprintNode? firstFalseNode; - if (context.VisitedBlocks.Contains(falseBlock.Name)) - { - firstFalseNode = context.BlockFirstNodes.GetValueOrDefault(falseBlock.Name); - } - else - { - firstFalseNode = ProcessBlockRecursive(falseBlock, context, null, x + 300, targetY + 200); - } - - if (firstFalseNode != null && firstFalseNode.Id != branch.Id && context.Blueprint.Nodes.Any(n => n.Id == firstFalseNode.Id)) - { - var actualTarget = GetFirstConnectableNode(firstFalseNode, context); - var falsePin = branch.OutputPins.FirstOrDefault(p => p.Name == "False"); - var actualExecIn = actualTarget?.InputPins.FirstOrDefault(p => p.Name == "Exec"); - - if (falsePin != null && actualExecIn != null && actualTarget != null && actualTarget.Id != branch.Id) - { - context.Blueprint.AddConnection(new BlueprintConnection - { - SourceNodeId = branch.Id, - SourcePinId = falsePin.Id, - TargetNodeId = actualTarget.Id, - TargetPinId = actualExecIn.Id - }); - } - } - } - } - } - - public void ProcessBranchNodeConnectionsInMain(BranchNode branch, FlowControlStatement flowCtrl, ConversionContext context) - { - double x = 350; - double targetY = 300; - - if (!string.IsNullOrEmpty(flowCtrl.TrueBlockName)) - { - var trueBlock = context.Script.GetBlockByName(flowCtrl.TrueBlockName); - if (trueBlock != null) - { - var firstTrueNode = ProcessBlockRecursive(trueBlock, context, null, x, targetY); - if (firstTrueNode != null) - { - var truePin = branch.OutputPins.FirstOrDefault(p => p.Name == "True"); - var firstExecIn = firstTrueNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); - if (truePin != null && firstExecIn != null) - { - context.Blueprint.AddConnection(new BlueprintConnection - { - SourceNodeId = branch.Id, - SourcePinId = truePin.Id, - TargetNodeId = firstTrueNode.Id, - TargetPinId = firstExecIn.Id - }); - } - } - } - } - - if (!string.IsNullOrEmpty(flowCtrl.FalseBlockName)) - { - var falseBlock = context.Script.GetBlockByName(flowCtrl.FalseBlockName); - if (falseBlock != null) - { - if (!context.VisitedBlocks.Contains(falseBlock.Name)) - { - var firstFalseNode = ProcessBlockRecursive(falseBlock, context, null, x, targetY + 200); - if (firstFalseNode != null && firstFalseNode.Id != branch.Id) - { - var falsePin = branch.OutputPins.FirstOrDefault(p => p.Name == "False"); - var firstExecIn = firstFalseNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); - if (falsePin != null && firstExecIn != null) - { - context.Blueprint.AddConnection(new BlueprintConnection - { - SourceNodeId = branch.Id, - SourcePinId = falsePin.Id, - TargetNodeId = firstFalseNode.Id, - TargetPinId = firstExecIn.Id - }); - } - } - } - } - } - } - - - public void ProcessLoopNodeConnections(LoopNode loop, BlockDefinition block, ConversionContext context, double x, double y) - { - var flowCtrl = block.Statements.FirstOrDefault(s => s is FlowControlStatement) as FlowControlStatement; - if (flowCtrl == null) return; - - double targetY = y + 150; - - if (!string.IsNullOrEmpty(flowCtrl.TrueBlockName)) - { - var loopBodyBlock = context.Script.GetBlockByName(flowCtrl.TrueBlockName); - if (loopBodyBlock != null) - { - context.LoopNodesByParentBlock[flowCtrl.TrueBlockName] = loop; - - var firstLoopNode = ProcessBlockRecursive(loopBodyBlock, context, null, x + 300, targetY); - if (firstLoopNode != null) - { - var loopBodyPin = loop.OutputPins.FirstOrDefault(p => p.Name == "LoopBody"); - var firstExecIn = firstLoopNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); - if (loopBodyPin != null && firstExecIn != null) - { - context.Blueprint.AddConnection(new BlueprintConnection - { - SourceNodeId = loop.Id, - SourcePinId = loopBodyPin.Id, - TargetNodeId = firstLoopNode.Id, - TargetPinId = firstExecIn.Id - }); - } - } - } - } - - if (!string.IsNullOrEmpty(flowCtrl.FalseBlockName)) - { - var afterLoopBlock = context.Script.GetBlockByName(flowCtrl.FalseBlockName); - if (afterLoopBlock != null) - { - var firstAfterLoopNode = ProcessBlockRecursive(afterLoopBlock, context, null, x + 300, targetY + 200); - if (firstAfterLoopNode != null) - { - var loopEndPin = loop.OutputPins.FirstOrDefault(p => p.Name == "LoopEnd"); - var firstExecIn = firstAfterLoopNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); - if (loopEndPin != null && firstExecIn != null) - { - context.Blueprint.AddConnection(new BlueprintConnection - { - SourceNodeId = loop.Id, - SourcePinId = loopEndPin.Id, - TargetNodeId = firstAfterLoopNode.Id, - TargetPinId = firstExecIn.Id - }); - } - } - } - } - - if (!string.IsNullOrEmpty(flowCtrl.LoopBodyEndReturnTo)) - { - context.LoopNodesByParentBlock[flowCtrl.LoopBodyEndReturnTo] = loop; - } - } - - public void ProcessLoopNodeConnectionsInMain(LoopNode loop, FlowControlStatement flowCtrl, ConversionContext context) - { - double x = 350; - double targetY = 300; - - if (!string.IsNullOrEmpty(flowCtrl.TrueBlockName)) - { - var loopBodyBlock = context.Script.GetBlockByName(flowCtrl.TrueBlockName); - if (loopBodyBlock != null) - { - context.LoopNodesByParentBlock[flowCtrl.TrueBlockName] = loop; - if (!string.IsNullOrEmpty(flowCtrl.LoopBodyEndReturnTo)) - { - context.LoopNodesByParentBlock[flowCtrl.LoopBodyEndReturnTo] = loop; - } - - var firstLoopNode = ProcessBlockRecursive(loopBodyBlock, context, null, x, targetY); - if (firstLoopNode != null) - { - var loopBodyPin = loop.OutputPins.FirstOrDefault(p => p.Name == "LoopBody"); - var firstExecIn = firstLoopNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); - if (loopBodyPin != null && firstExecIn != null) - { - context.Blueprint.AddConnection(new BlueprintConnection - { - SourceNodeId = loop.Id, - SourcePinId = loopBodyPin.Id, - TargetNodeId = firstLoopNode.Id, - TargetPinId = firstExecIn.Id - }); - } - } - } - } - - if (!string.IsNullOrEmpty(flowCtrl.FalseBlockName)) - { - var afterLoopBlock = context.Script.GetBlockByName(flowCtrl.FalseBlockName); - if (afterLoopBlock != null) - { - var firstAfterLoopNode = ProcessBlockRecursive(afterLoopBlock, context, null, x, targetY + 200); - if (firstAfterLoopNode != null) - { - var loopEndPin = loop.OutputPins.FirstOrDefault(p => p.Name == "LoopEnd"); - var firstExecIn = firstAfterLoopNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); - if (loopEndPin != null && firstExecIn != null) - { - context.Blueprint.AddConnection(new BlueprintConnection - { - SourceNodeId = loop.Id, - SourcePinId = loopEndPin.Id, - TargetNodeId = firstAfterLoopNode.Id, - TargetPinId = firstExecIn.Id - }); - } - } - } - } - - if (!string.IsNullOrEmpty(flowCtrl.LoopBodyEndReturnTo)) - { - context.LoopNodesByParentBlock[flowCtrl.LoopBodyEndReturnTo] = loop; - } - } - - - public void ProcessLoopBlock(BlockDefinition block, ConversionContext context, INodeCreationService nodeCreationService) - { - if (context.VisitedBlocks.Contains(block.Name)) return; - context.VisitedBlocks.Add(block.Name); - - var parentBlockName = block.ParentBlockName ?? block.Name; - context.CurrentParentBlock = parentBlockName; - - var nodes = new List(); - var currentY = 50.0; - - foreach (var statement in block.Statements) - { - var node = ProcessStatement(statement, context, nodeCreationService, 50, currentY); - if (node != null) - { - context.Blueprint.AddNode(node); - nodes.Add(node); - currentY += 120; - } - } - - _connectionCreationService.LinkNodesWithExec(nodes, context.Blueprint); - - foreach (var statement in block.Statements) - { - if (statement is FlowControlStatement flowCtrl) - { - if (!string.IsNullOrEmpty(flowCtrl.TrueBlockName)) - { - var trueBlock = context.Script.GetBlockByName(flowCtrl.TrueBlockName); - if (trueBlock != null && !context.VisitedBlocks.Contains(trueBlock.Name)) - ProcessBlockRecursive(trueBlock, context, nodeCreationService, 350, 50); - } - if (!string.IsNullOrEmpty(flowCtrl.FalseBlockName)) - { - var falseBlock = context.Script.GetBlockByName(flowCtrl.FalseBlockName); - if (falseBlock != null && !context.VisitedBlocks.Contains(falseBlock.Name)) - ProcessBlockRecursive(falseBlock, context, nodeCreationService, 350, 200); - } - } - } - } - - public void HandleLoopBodyEnd(FlowControlStatement flowCtrl, ConversionContext context) - { - if (!string.IsNullOrEmpty(flowCtrl.LoopBodyEndReturnTo)) - { - if (context.LoopNodesByParentBlock.TryGetValue(flowCtrl.LoopBodyEndReturnTo, out var loopNode)) - { - var lastNode = context.LastProcessedNode; - - if (lastNode != null) - { - var loopConditionIn = loopNode.InputPins.FirstOrDefault(p => p.Name == "Condition"); - BlueprintNode? bleNode = null; - - if (loopConditionIn != null) - { - var conditionConn = context.Blueprint.Connections - .FirstOrDefault(c => c.TargetNodeId == loopNode.Id && - c.TargetPinId == loopConditionIn.Id); - if (conditionConn != null) - { - bleNode = context.Blueprint.Nodes.FirstOrDefault(n => n.Id == conditionConn.SourceNodeId); - } - } - - if (bleNode == null && !string.IsNullOrEmpty(flowCtrl.ConditionExpression)) - { - var conditionVarName = flowCtrl.ConditionExpression.Trim(); - var source = context.VariableSources.Values - .FirstOrDefault(s => s.PubVarName == conditionVarName); - if (source != null) - { - bleNode = source.Node; - } - } - - if (bleNode != null) - { - var lastExecOut = lastNode.OutputPins.FirstOrDefault(p => p.Name == "Exec"); - var bleExecIn = bleNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); - var bleExecOut = bleNode.OutputPins.FirstOrDefault(p => p.Name == "Exec"); - var loopExecIn = loopNode.InputPins.FirstOrDefault(p => p.Name == "Exec"); - - if (lastExecOut != null && bleExecIn != null && lastNode.Id != bleNode.Id) - { - var hasConnection = context.Blueprint.Connections - .Any(c => c.SourceNodeId == lastNode.Id && - c.SourcePinId == lastExecOut.Id && - c.TargetNodeId == bleNode.Id && - c.TargetPinId == bleExecIn.Id); - if (!hasConnection) - { - context.Blueprint.AddConnection(new BlueprintConnection - { - SourceNodeId = lastNode.Id, - SourcePinId = lastExecOut.Id, - TargetNodeId = bleNode.Id, - TargetPinId = bleExecIn.Id - }); - } - } - - if (bleExecOut != null && loopExecIn != null) - { - var hasConnection = context.Blueprint.Connections - .Any(c => c.SourceNodeId == bleNode.Id && - c.SourcePinId == bleExecOut.Id && - c.TargetNodeId == loopNode.Id && - c.TargetPinId == loopExecIn.Id); - if (!hasConnection) - { - context.Blueprint.AddConnection(new BlueprintConnection - { - SourceNodeId = bleNode.Id, - SourcePinId = bleExecOut.Id, - TargetNodeId = loopNode.Id, - TargetPinId = loopExecIn.Id - }); - } - } - } - } - - context.LoopNodesByParentBlock[flowCtrl.LoopBodyEndReturnTo] = loopNode; - } - } - } - - public BlueprintNode? GetFirstConnectableNode(BlueprintNode node, ConversionContext context) - { - return node; - } - - - private BlueprintNode? CreateControlFlowNode(FlowControlStatement statement) - { - switch (statement.ControlType) - { - case FlowControlType.Branch: - return new BranchNode(); - case FlowControlType.Loop: - return new LoopNode(); - case FlowControlType.Break: - return new BreakNode(); - default: - return null; - } - } - - private BlueprintNode? CreateActionNodeFromExpression(string expression, ConversionContext context) - { - var parsed = TryParseInvocationExpression(expression); - if (parsed == null) - return null; - - var (funcName, args, assignment) = parsed.Value; - - if (funcName == "Get") - { - var varName = args?.Arguments.FirstOrDefault()?.Expression.ToString() ?? string.Empty; - - var getNode = new GetNode - { - VarName = varName, - Name = "Get:" + varName - }; - Log.Debug("Created GetNode: VarName={VarName}, Id={NodeId}", varName, getNode.Id); - - var getValuePin = getNode.OutputPins.First(p => p.Name == "Value"); - - if (assignment != null) - { - var assignedVarName = assignment.Left.ToString(); - if (assignedVarName != "_") - { - var isPubVar = context.Script.PubVarBlock?.Variables.Any(v => v.Name == assignedVarName) == true; - context.VariableSources["__get_" + assignedVarName + "_" + Guid.NewGuid().ToString("N") + "__"] = new VariableSource - { - Node = getNode, - Pin = getValuePin, - PubVarName = isPubVar ? assignedVarName : null - }; - Log.Debug("Registered GetNode output as VariableSource: {VarName}, IsPubVar={IsPubVar}", - assignedVarName, isPubVar); - } - } - - return getNode; - } - - if (funcName == "Set") - { - var argList = args?.Arguments.ToList() ?? new List(); - if (argList.Count >= 2) - { - var varName = argList[0].Expression.ToString(); - var valueExpr = argList[1].Expression; - var setNode = new SetNode - { - VarName = varName, - Name = "Set:" + varName - }; - Log.Debug("Created SetNode: VarName={VarName}, Id={NodeId}", varName, setNode.Id); - - var setValuePin = setNode.InputPins.FirstOrDefault(p => p.Name == "Value"); - if (setValuePin != null && valueExpr != null) - { - _connectionCreationService.ProcessArgumentExpression(valueExpr, setValuePin, setNode, context); - } - - return setNode; - } - return null; - } - - if (funcName == "Print") - { - var printNode = new PrintNode(); - var arg = args?.Arguments.FirstOrDefault()?.Expression.ToString() ?? string.Empty; - _connectionCreationService.CreateDataConnectionsForExpression(arg, printNode, context); - return printNode; - } - - if (funcName == "Pause") - { - var pauseNode = new PauseNode(); - var arg = args?.Arguments.FirstOrDefault()?.Expression.ToString() ?? string.Empty; - _connectionCreationService.CreateDataConnectionsForExpression(arg, pauseNode, context); - return pauseNode; - } - - if (assignment != null) - { - var varName = assignment.Left.ToString(); - - if (funcName is "Loop" or "Branch" or "LoopBodyEnd" or "Break") - { - Log.Debug("Skipped creating CallNode for flow control function: {FuncName}", funcName); - return null; - } - - var existingSource = context.VariableSources.Values - .FirstOrDefault(s => s.PubVarName == varName); - - BlueprintNode callNode; - if (existingSource != null) - { - callNode = existingSource.Node; - Log.Debug("Reused existing source node {NodeName} for {VarName}", callNode.Name, varName); - } - else - { - var isHelper = context.Script.HelperFunctions.Any(h => h.Name == funcName); - if (isHelper) - callNode = new CallHelperNode { HelperFunctionName = funcName, Name = "Helper:" + funcName }; - else - callNode = new CallNode { FunctionName = funcName, Name = "Call:" + funcName }; - - _connectionCreationService.AddParameterPins(callNode, args?.Arguments); - _connectionCreationService.CreateDataConnectionsForArguments(args?.Arguments, callNode, context); - - var returnPin = callNode.OutputPins.FirstOrDefault(p => p.Name == "Return"); - if (returnPin != null) - { - var isPubVar = context.Script.PubVarBlock?.Variables.Any(v => v.Name == varName) == true; - context.VariableSources[varName] = new VariableSource - { - Node = callNode, - Pin = returnPin, - PubVarName = isPubVar ? varName : null - }; - } - } - - return callNode; - } - - if (funcName is "Loop" or "Branch" or "LoopBodyEnd" or "Break") - { - Log.Debug("Skipped creating CallNode for flow control/builtin function: {FuncName}", funcName); - return null; - } - - BlueprintNode callNode2; - var isHelper2 = context.Script.HelperFunctions.Any(h => h.Name == funcName); - if (isHelper2) - callNode2 = new CallHelperNode { HelperFunctionName = funcName, Name = "Helper:" + funcName }; - else - callNode2 = new CallNode { FunctionName = funcName, Name = "Call:" + funcName }; - - _connectionCreationService.AddParameterPins(callNode2, args?.Arguments); - _connectionCreationService.CreateDataConnectionsForArguments(args?.Arguments, callNode2, context); - - return callNode2; - } - - private (string? funcName, ArgumentListSyntax? args, AssignmentExpressionSyntax? assignment)? TryParseInvocationExpression(string expression) - { - var wrappedCode = "_ = " + expression + ";"; - var syntaxTree = CSharpSyntaxTree.ParseText(wrappedCode, cancellationToken: CancellationToken.None); - var root = syntaxTree.GetCompilationUnitRoot(); - - var globalStmt = root.Members.FirstOrDefault() as GlobalStatementSyntax; - var stmt = globalStmt?.Statement as ExpressionStatementSyntax; - - if (stmt?.Expression is AssignmentExpressionSyntax outerAssignment) - { - ExpressionSyntax rightExpr = outerAssignment.Right; - AssignmentExpressionSyntax? innermostAssignment = null; - while (rightExpr is AssignmentExpressionSyntax nestedAssignment) - { - innermostAssignment = nestedAssignment; - rightExpr = nestedAssignment.Right; - } - - if (rightExpr is InvocationExpressionSyntax invoke) - { - var methodName = _connectionCreationService.GetMethodNameFromExpression(invoke); - var assignmentToReturn = innermostAssignment ?? outerAssignment; - return (methodName, invoke.ArgumentList, assignmentToReturn); - } - } - else if (stmt?.Expression is InvocationExpressionSyntax directInvoke) - { - var methodName = _connectionCreationService.GetMethodNameFromExpression(directInvoke); - return (methodName, directInvoke.ArgumentList, null); - } - return null; - } -} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/IConnectionCreationService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/IConnectionCreationService.cs deleted file mode 100644 index f0de518c..00000000 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/IConnectionCreationService.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Collections.Generic; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; -using KitX.Core.Contract.Workflow; - -namespace KitX.Core.Workflow.Blueprint; - -public interface IConnectionCreationService -{ - void CreateExecConnection(BlueprintNode source, BlueprintNode target, Contract.Workflow.Blueprint blueprint); - - void CreateDataConnection(BlueprintNode source, string sourcePin, BlueprintNode target, string targetPin, string? pubVarName, Contract.Workflow.Blueprint blueprint); - - void LinkNodesWithExec(List nodes, Contract.Workflow.Blueprint blueprint); - - void CreateDataConnectionsForExpression(string expression, BlueprintNode targetNode, ConversionContext context); - - void CreateDataConnectionsForArguments(SeparatedSyntaxList? arguments, BlueprintNode callNode, ConversionContext context); - - void AddParameterPins(BlueprintNode node, SeparatedSyntaxList? arguments); - - void CreateDataConnectionForVariable(string varName, BlueprintPin targetPin, BlueprintNode callNode, ConversionContext context); - - void CreateDataConnectionForVariable(string varName, BlueprintNode targetNode, ConversionContext context); - - void ProcessArgumentExpression(ExpressionSyntax expr, BlueprintPin targetPin, BlueprintNode callNode, ConversionContext context); - - bool IsVariableReference(string token); - - void CreateConstNodeForLiteral(LiteralExpressionSyntax literal, BlueprintNode targetNode, ConversionContext context); - - void CreateConstNodeForLiteralWithPin(LiteralExpressionSyntax literal, BlueprintPin targetPin, BlueprintNode targetNode, ConversionContext context); - - string GetMethodNameFromExpression(InvocationExpressionSyntax invoke); -} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/IFlowProcessingService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/IFlowProcessingService.cs deleted file mode 100644 index 7e7f56cd..00000000 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/IFlowProcessingService.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System.Collections.Generic; -using Microsoft.CodeAnalysis.CSharp.Syntax; -using KitX.Core.Contract.Workflow; - -namespace KitX.Core.Workflow.Blueprint; - -public interface IFlowProcessingService -{ - void ProcessConstBlock(BlockDefinition block, ConversionContext context); - - void ProcessPubVarBlock(BlockDefinition block, ConversionContext context); - - void ProcessMainBlock(BlockDefinition block, ConversionContext context, INodeCreationService nodeCreationService); - - BlueprintNode? ProcessStatement(BlockStatement statement, ConversionContext context, INodeCreationService nodeCreationService, double x, double y); - - BlueprintNode? ProcessFlowControlStatement(FlowControlStatement flowCtrl, ConversionContext context, INodeCreationService nodeCreationService, double x, double y); - - BlueprintNode? ProcessBlockRecursive(BlockDefinition block, ConversionContext context, INodeCreationService nodeCreationService, double x, double y); - - void ProcessBranchNodeConnections(BranchNode branch, BlockDefinition block, ConversionContext context, double x, double y); - - void ProcessBranchNodeConnectionsInMain(BranchNode branch, FlowControlStatement flowCtrl, ConversionContext context); - - void ProcessLoopNodeConnections(LoopNode loop, BlockDefinition block, ConversionContext context, double x, double y); - - void ProcessLoopNodeConnectionsInMain(LoopNode loop, FlowControlStatement flowCtrl, ConversionContext context); - - void ProcessLoopBlock(BlockDefinition block, ConversionContext context, INodeCreationService nodeCreationService); - - void HandleLoopBodyEnd(FlowControlStatement flowCtrl, ConversionContext context); - - BlueprintNode? GetFirstConnectableNode(BlueprintNode node, ConversionContext context); -} diff --git a/KitX Clients/KitX Dashboard b/KitX Clients/KitX Dashboard index eb4619c6..ae41aa0d 160000 --- a/KitX Clients/KitX Dashboard +++ b/KitX Clients/KitX Dashboard @@ -1 +1 @@ -Subproject commit eb4619c65bde4112de736aad5aa899ad31e743ae +Subproject commit ae41aa0d088a88991cd3a3f1be77b173536f1978 diff --git a/KitX Standard b/KitX Standard index 10463781..56934260 160000 --- a/KitX Standard +++ b/KitX Standard @@ -1 +1 @@ -Subproject commit 104637811039d9755a3b80c22304feaa645d6126 +Subproject commit 56934260baa68c2116fd96bc01236b6388e08eff From 1d7085b9086c5a0a06a20e5a16cf985dc1751ac5 Mon Sep 17 00:00:00 2001 From: StarInk Date: Mon, 6 Apr 2026 02:58:46 +0200 Subject: [PATCH 029/110] =?UTF-8?q?=F0=9F=A7=A9=20Refactor(Blueprint):=20?= =?UTF-8?q?=E5=BC=95=E5=85=A5=E8=8A=82=E7=82=B9=E6=B3=A8=E5=86=8C=E8=A1=A8?= =?UTF-8?q?=E4=B8=8E=E7=AD=96=E7=95=A5=E6=A8=A1=E5=BC=8F=EF=BC=8C=E6=B6=88?= =?UTF-8?q?=E9=99=A4(=E6=B7=BB=E5=8A=A0=E6=96=B0=E5=86=85=E7=BD=AE?= =?UTF-8?q?=E8=8A=82=E7=82=B9=E6=97=B6=E7=9A=84)=E6=95=A3=E5=BC=B9?= =?UTF-8?q?=E5=BC=8F=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../KitX.Core.BluePrint.Test/Program.cs | 4 +- .../DI/CoreServiceCollectionExtensions.cs | 21 +- .../BlockScriptToBlueprintConverter.cs | 13 +- .../BlueprintToBlockScriptConverter.cs | 250 ++++++------------ .../BranchNodeExportStrategy.cs | 30 +++ .../BreakNodeExportStrategy.cs | 21 ++ .../CallHelperNodeExportStrategy.cs | 23 ++ .../CallNodeExportStrategy.cs | 23 ++ .../LoopNodeExportStrategy.cs | 30 +++ .../PauseNodeExportStrategy.cs | 22 ++ .../PrintNodeExportStrategy.cs | 22 ++ .../Workflow/Blueprint/INodeExportStrategy.cs | 75 ++++++ .../Workflow/Blueprint/LayoutService.cs | 90 ++++--- .../Workflow/Blueprint/NodeCreationService.cs | 162 ------------ .../Workflow/Blueprint/NodeRegistry.cs | 67 +++++ .../Blueprint/NodeTemplateProvider.cs | 244 ----------------- .../Blueprint/Pipeline/NodeBuilder.cs | 51 ++-- KitX Clients/KitX Dashboard | 2 +- KitX Standard | 2 +- 19 files changed, 514 insertions(+), 638 deletions(-) create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/BranchNodeExportStrategy.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/BreakNodeExportStrategy.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/CallHelperNodeExportStrategy.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/CallNodeExportStrategy.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/LoopNodeExportStrategy.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/PauseNodeExportStrategy.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/PrintNodeExportStrategy.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/INodeExportStrategy.cs delete mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeCreationService.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeRegistry.cs delete mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeTemplateProvider.cs diff --git a/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs b/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs index db01cf36..e533df1f 100644 --- a/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs +++ b/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs @@ -20,7 +20,7 @@ public static void Main(string[] args) var sp = services.BuildServiceProvider(); var parser = sp.GetRequiredService(); - var nodeFactory = sp.GetRequiredService(); + var nodeRegistry = sp.GetRequiredService(); var layoutService = sp.GetRequiredService(); Console.WriteLine("DI initialized.\n"); @@ -51,7 +51,7 @@ public static void Main(string[] args) } }; - var converter = new BlockScriptToBlueprintConverter(parser, nodeFactory, layoutService); + var converter = new BlockScriptToBlueprintConverter(parser, nodeRegistry, layoutService); // ── Test A: Pre-expanded format (already in test script) ── Console.WriteLine("┌──────────────────────────────────────────┐"); diff --git a/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs b/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs index 2aff83f1..25ba19d6 100644 --- a/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs +++ b/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs @@ -24,6 +24,7 @@ using KitX.Core.Workflow; using KitX.Core.Workflow.BlockScripting; using KitX.Core.Workflow.Blueprint; +using KitX.Core.Workflow.Blueprint.ExportStrategies; using KitX.Core.Event; using Serilog; @@ -199,7 +200,7 @@ public static IServiceCollection AddCoreServices(this IServiceCollection service // Blueprint Sub-services (must be registered before IBlueprintService) Log.Information("Registering Blueprint sub-services..."); - services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -209,18 +210,20 @@ public static IServiceCollection AddCoreServices(this IServiceCollection service Log.Information("Registering IBlueprintToBlockScriptConverter..."); services.AddSingleton(); + // Blueprint Export Strategies + Log.Information("Registering node export strategies..."); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + // Blueprint Services Log.Information("Registering IBlueprintService..."); services.AddSingleton(); - // Node Template Provider - Log.Information("Registering INodeTemplateProvider..."); - services.AddSingleton(provider => - { - var service = new NodeTemplateProvider(); - return service; - }); - Log.Information("AddCoreServices completed."); return services; } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlockScriptToBlueprintConverter.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlockScriptToBlueprintConverter.cs index c4eea90f..1b0f1ee4 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlockScriptToBlueprintConverter.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlockScriptToBlueprintConverter.cs @@ -14,7 +14,7 @@ namespace KitX.Core.Workflow.Blueprint; public class BlockScriptToBlueprintConverter : IBlockScriptToBlueprintConverter { private readonly IBlockScriptParser _parser; - private readonly INodeCreationService _nodeFactory; + private readonly INodeRegistry _nodeRegistry; private readonly ILayoutService _layoutService; /// @@ -24,11 +24,11 @@ public class BlockScriptToBlueprintConverter : IBlockScriptToBlueprintConverter public BlockScriptToBlueprintConverter( IBlockScriptParser parser, - INodeCreationService nodeFactory, + INodeRegistry nodeRegistry, ILayoutService layoutService) { _parser = parser; - _nodeFactory = nodeFactory; + _nodeRegistry = nodeRegistry; _layoutService = layoutService; } @@ -67,7 +67,7 @@ public Contract.Workflow.Blueprint Convert(BlockScript script) context.FormattedScript.Blocks.Sum(b => b.Statements.Count)); // ── Phase 3: Node creation + exec edges + PubVar reuse ── - var nodeBuilder = new NodeBuilder(_nodeFactory, helpers); + var nodeBuilder = new NodeBuilder(_nodeRegistry, helpers); nodeBuilder.Build(context.FormattedScript, context); Log.Debug("[Converter] Phase 3: {NodeCount} nodes, {ExecEdgeCount} exec edges", context.AllNodes.Count, context.ExecEdges.Count); @@ -104,7 +104,10 @@ private void Phase1_ProcessConstAndPubVar(PipelineContext context) foreach (var varDecl in context.Script.ConstBlock.Variables) { var value = varDecl.DefaultValue?.ToString() ?? varDecl.InitialValueExpression ?? ""; - var constNode = _nodeFactory.CreateConstNode(varDecl.Name, varDecl.Type, value); + var constNode = (ConstNode)_nodeRegistry.Create(BlueprintNodeType.Const); + constNode.ConstName = varDecl.Name; + constNode.ConstType = varDecl.Type; + constNode.ConstValue = value; context.ConstNodes[varDecl.Name] = constNode; context.AllNodes.Add(constNode); } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintToBlockScriptConverter.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintToBlockScriptConverter.cs index 85a5ca9a..9c9835cc 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintToBlockScriptConverter.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintToBlockScriptConverter.cs @@ -7,17 +7,36 @@ namespace KitX.Core.Workflow.Blueprint; /// -/// Converts Blueprint to BlockScript +/// Converts Blueprint to BlockScript using strategy pattern for node type dispatch. +/// Each node type's conversion logic lives in a dedicated INodeExportStrategy implementation. /// -public class BlueprintToBlockScriptConverter : IBlueprintToBlockScriptConverter +public class BlueprintToBlockScriptConverter : IBlueprintToBlockScriptConverter, INodeExportHelper { + private readonly Dictionary _strategies; + + public BlueprintToBlockScriptConverter(IEnumerable strategies) + { + _strategies = strategies.ToDictionary(s => s.NodeType); + } + + /// + public Contract.Workflow.Blueprint Blueprint { get; private set; } = null!; + + /// + /// Converts a blueprint to BlockScript source code string. + /// public string Convert(Contract.Workflow.Blueprint blueprint) { return ConvertToBlockScript(blueprint).SourceCode; } + /// + /// Converts a blueprint to a BlockScript object. + /// public BlockScript ConvertToBlockScript(Contract.Workflow.Blueprint blueprint) { + Blueprint = blueprint; + var context = new ReverseConversionContext { Blueprint = blueprint, @@ -36,7 +55,6 @@ public BlockScript ConvertToBlockScript(Contract.Workflow.Blueprint blueprint) var entryNode = blueprint.Nodes.FirstOrDefault(n => n.NodeType == BlueprintNodeType.Entry); if (entryNode != null) { - // Get nodes in main execution flow (from Entry to first control flow) var mainFlow = GetMainExecutionFlow(blueprint, entryNode); var mainBlock = GenerateBlock(context, mainFlow, "MainBlock"); context.Script.MainBlock = mainBlock; @@ -51,6 +69,52 @@ public BlockScript ConvertToBlockScript(Contract.Workflow.Blueprint blueprint) return context.Script; } + // ────────────────────────────────────────────── + // INodeExportHelper implementation + // ────────────────────────────────────────────── + + /// + public string GetInputValue(BlueprintNode node, string pinName) + { + var pin = node.InputPins.FirstOrDefault(p => p.Name == pinName); + if (pin == null) return string.Empty; + + var dataConn = Blueprint.Connections.FirstOrDefault(c => c.TargetPinId == pin.Id); + if (dataConn == null) return pin.DefaultValue ?? string.Empty; + + var sourceNode = Blueprint.GetNodeById(dataConn.SourceNodeId); + if (sourceNode is ConstNode constNode) + return constNode.ConstName; + + return dataConn.PubVarName ?? pin.DefaultValue ?? string.Empty; + } + + /// + public string GetInputArgs(BlueprintNode node) + { + var args = new List(); + foreach (var pin in node.InputPins) + { + if (pin.Name != "Exec") + { + args.Add(GetInputValue(node, pin.Name)); + } + } + return string.Join(", ", args); + } + + // ────────────────────────────────────────────── + // Core conversion logic (strategy-driven) + // ────────────────────────────────────────────── + + private BlockStatement? ConvertNodeToStatement(BlueprintNode node, Contract.Workflow.Blueprint blueprint) + { + if (_strategies.TryGetValue(node.NodeType, out var strategy)) + return strategy.ToStatement(node, this); + + return null; + } + private void GeneratePubVarBlock(ReverseConversionContext context) { var pubVarBlock = new BlockDefinition @@ -59,7 +123,6 @@ private void GeneratePubVarBlock(ReverseConversionContext context) Name = "PubVarBlock" }; - // Also create PubVars for data connections that don't have explicit PubVar var dataConnections = context.Blueprint.Connections .Where(c => !IsExecConnection(context.Blueprint, c)) .ToList(); @@ -68,7 +131,6 @@ private void GeneratePubVarBlock(ReverseConversionContext context) { if (string.IsNullOrEmpty(conn.PubVarName)) { - // Create a new PubVar for this connection var pubVarName = $"temp_{context.PubVarIndex++}"; conn.PubVarName = pubVarName; pubVarBlock.Variables.Add(new VariableDeclaration @@ -133,14 +195,13 @@ private void CollectMainFlow(Contract.Workflow.Blueprint blueprint, BlueprintNod flow.Add(node); - // Check if this is a control flow node - if (node is BranchNode || node is LoopNode) + // Use strategy to check if this is a control flow node + if (_strategies.TryGetValue(node.NodeType, out var strategy) && strategy.IsControlFlow) { hitControlFlow = true; return; } - // Get next Exec connection var execOut = node.OutputPins.FirstOrDefault(p => p.Name == "Exec"); if (execOut == null) { @@ -173,47 +234,24 @@ private void ProcessSubGraphs(Contract.Workflow.Blueprint blueprint, ReverseConv foreach (var node in blueprint.Nodes) { - if (node is BranchNode branch) - { - ProcessBranchSubGraphs(blueprint, branch, context, processedTargets); - } - else if (node is LoopNode loop) + if (!_strategies.TryGetValue(node.NodeType, out var strategy) || !strategy.IsControlFlow) + continue; + + foreach (var arm in strategy.GetOutputArms(node)) { - ProcessLoopSubGraphs(blueprint, loop, context, processedTargets); + ProcessOutputArm(blueprint, node, arm.PinName, arm.IsLoopback, + context, processedTargets); } } } - private void ProcessBranchSubGraphs(Contract.Workflow.Blueprint blueprint, BranchNode branch, - ReverseConversionContext context, HashSet processedTargets) - { - ProcessOutputArm(blueprint, branch, "True", context, processedTargets, null, - (flow, blockName) => flow.TrueBlockName = blockName); - ProcessOutputArm(blueprint, branch, "False", context, processedTargets, null, - (flow, blockName) => flow.FalseBlockName = blockName); - } - - private void ProcessLoopSubGraphs(Contract.Workflow.Blueprint blueprint, LoopNode loop, - ReverseConversionContext context, HashSet processedTargets) - { - ProcessOutputArm(blueprint, loop, "LoopBody", context, processedTargets, loop.Id, - (flow, blockName) => flow.TrueBlockName = blockName); - ProcessOutputArm(blueprint, loop, "LoopEnd", context, processedTargets, null, - (flow, blockName) => flow.FalseBlockName = blockName); - } - - /// - /// Processes a single output arm (True/False for Branch, LoopBody/LoopEnd for Loop). - /// Extracted to eliminate duplication between Branch and Loop sub-graph processing. - /// private void ProcessOutputArm( Contract.Workflow.Blueprint blueprint, BlueprintNode controlNode, string outputPinName, + bool isLoopback, ReverseConversionContext context, - HashSet processedTargets, - string? loopbackTargetId, - Action setBlockName) + HashSet processedTargets) { var pin = controlNode.OutputPins.FirstOrDefault(p => p.Name == outputPinName); if (pin == null) return; @@ -225,13 +263,20 @@ private void ProcessOutputArm( if (targetNode == null) return; var blockName = $"Block_{context.BlockIndex++}"; + var loopbackTargetId = isLoopback ? controlNode.Id : null; var block = CollectSubGraph(blueprint, targetNode, context, processedTargets, loopbackTargetId); context.Script.NamedBlocks[blockName] = block; processedTargets.Add(conn.TargetNodeId); if (context.ControlFlowMap.TryGetValue(controlNode.Id, out var flow)) { - setBlockName(flow, blockName); + // Determine which block name property to set based on arm index + var arms = _strategies[controlNode.NodeType].GetOutputArms(controlNode).ToList(); + var armIndex = arms.FindIndex(a => a.PinName == outputPinName); + if (armIndex == 0) + flow.TrueBlockName = blockName; + else + flow.FalseBlockName = blockName; } } @@ -257,20 +302,17 @@ private void CollectNodesRecursive(Contract.Workflow.Blueprint blueprint, Bluepr if (visited.Contains(node.Id)) return; visited.Add(node.Id); - // Add node to block var nodeStatement = ConvertNodeToStatement(node, blueprint); if (nodeStatement != null) { block.Statements.Add(nodeStatement); - // Track FlowControlStatements for later setting target block names if (nodeStatement is FlowControlStatement flow) { context.ControlFlowMap[node.Id] = flow; } } - // Process all output connections foreach (var outPin in node.OutputPins) { if (outPin.Name == "Exec") @@ -281,16 +323,15 @@ private void CollectNodesRecursive(Contract.Workflow.Blueprint blueprint, Bluepr var nextNode = blueprint.GetNodeById(conn.TargetNodeId); if (nextNode != null) { - // Check if this is a loopback to the loop node if (nextNode.Id == loopbackTargetId) { - // Add LoopBodyEnd statement to return to loop block.Statements.Add(new FlowControlStatement { ControlType = FlowControlType.LoopBodyEnd, - LoopBodyEndReturnTo = loopbackTargetId + SourceCode = "LoopBodyEnd();", + LineNumber = 1 }); - return; // End of loop body + return; } CollectNodesRecursive(blueprint, nextNode, block, context, visited, processedTargets, loopbackTargetId); @@ -300,112 +341,6 @@ private void CollectNodesRecursive(Contract.Workflow.Blueprint blueprint, Bluepr } } - private BlockStatement? ConvertNodeToStatement(BlueprintNode node, Contract.Workflow.Blueprint blueprint) - { - switch (node) - { - case PrintNode print: - var printValue = GetInputValue(print, "Value", blueprint); - return new ExpressionStatement - { - Expression = $"Print({printValue});", - SourceCode = $"Print({printValue});", - LineNumber = 1 - }; - - case PauseNode pause: - var ms = GetInputValue(pause, "Milliseconds", blueprint); - return new ExpressionStatement - { - Expression = $"Pause({ms});", - SourceCode = $"Pause({ms});", - LineNumber = 1 - }; - - case CallNode call: - var callArgs = GetInputArgs(call, blueprint); - return new ExpressionStatement - { - Expression = $"{call.FunctionName}({callArgs});", - SourceCode = $"{call.FunctionName}({callArgs});", - LineNumber = 1 - }; - - case CallHelperNode callHelper: - var helperArgs = GetInputArgs(callHelper, blueprint); - return new ExpressionStatement - { - Expression = $"{callHelper.HelperFunctionName}({helperArgs});", - SourceCode = $"{callHelper.HelperFunctionName}({helperArgs});", - LineNumber = 1 - }; - - case BreakNode: - return new FlowControlStatement - { - ControlType = FlowControlType.Break, - SourceCode = "Break();", - LineNumber = 1 - }; - - case BranchNode branch: - var branchFlow = new FlowControlStatement - { - ControlType = FlowControlType.Branch, - ConditionExpression = GetConditionExpression(branch, blueprint), - SourceCode = $"Branch({GetConditionExpression(branch, blueprint)}, \"\", \"\");", - LineNumber = 1 - }; - return branchFlow; - - case LoopNode loop: - var loopFlow = new FlowControlStatement - { - ControlType = FlowControlType.Loop, - ConditionExpression = GetConditionExpression(loop, blueprint), - SourceCode = $"Loop({GetConditionExpression(loop, blueprint)}, \"\", \"\");", - LineNumber = 1 - }; - return loopFlow; - - default: - return null; - } - } - - private string GetConditionExpression(BlueprintNode node, Contract.Workflow.Blueprint blueprint) - => GetInputValue(node, "Condition", blueprint); - - private string GetInputValue(BlueprintNode node, string pinName, Contract.Workflow.Blueprint blueprint) - { - var pin = node.InputPins.FirstOrDefault(p => p.Name == pinName); - if (pin == null) return string.Empty; - - // Find the data connection to this pin - var dataConn = blueprint.Connections.FirstOrDefault(c => c.TargetPinId == pin.Id); - if (dataConn == null) return pin.DefaultValue ?? string.Empty; - - var sourceNode = blueprint.GetNodeById(dataConn.SourceNodeId); - if (sourceNode is ConstNode constNode) - return constNode.ConstName; - - // For other nodes, return the PubVar name if available - return dataConn.PubVarName ?? pin.DefaultValue ?? string.Empty; - } - - private string GetInputArgs(BlueprintNode node, Contract.Workflow.Blueprint blueprint) - { - var args = new List(); - foreach (var pin in node.InputPins) - { - if (pin.Name != "Exec") - { - args.Add(GetInputValue(node, pin.Name, blueprint)); - } - } - return string.Join(", ", args); - } - private BlockDefinition GenerateBlock(ReverseConversionContext context, List nodes, string blockName) { @@ -422,7 +357,6 @@ private BlockDefinition GenerateBlock(ReverseConversionContext context, { block.Statements.Add(statement); - // Track FlowControlStatements for later setting target block names if (statement is FlowControlStatement flow) { context.ControlFlowMap[node.Id] = flow; @@ -444,7 +378,6 @@ private void GenerateSourceCode(BlockScript script) { var sb = new StringBuilder(); - // #ConstBlock if (script.ConstBlock != null && script.ConstBlock.Variables.Count > 0) { sb.AppendLine("#ConstBlock"); @@ -455,7 +388,6 @@ private void GenerateSourceCode(BlockScript script) sb.AppendLine(); } - // #PubVarBlock if (script.PubVarBlock != null && script.PubVarBlock.Variables.Count > 0) { sb.AppendLine("#PubVarBlock"); @@ -466,7 +398,6 @@ private void GenerateSourceCode(BlockScript script) sb.AppendLine(); } - // #MainBlock if (script.MainBlock != null) { sb.AppendLine("#MainBlock"); @@ -477,7 +408,6 @@ private void GenerateSourceCode(BlockScript script) sb.AppendLine(); } - // NamedBlocks foreach (var kvp in script.NamedBlocks) { sb.AppendLine($"#Block {kvp.Key}"); @@ -497,10 +427,6 @@ private class ReverseConversionContext public required BlockScript Script { get; set; } public int PubVarIndex { get; set; } public int BlockIndex { get; set; } - - /// - /// Maps node ID to its FlowControlStatement for setting target block names later - /// public Dictionary ControlFlowMap { get; set; } = new(); } } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/BranchNodeExportStrategy.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/BranchNodeExportStrategy.cs new file mode 100644 index 00000000..fa99fe29 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/BranchNodeExportStrategy.cs @@ -0,0 +1,30 @@ +using KitX.Core.Contract.Workflow; + +namespace KitX.Core.Workflow.Blueprint.ExportStrategies; + +public class BranchNodeExportStrategy : INodeExportStrategy +{ + public BlueprintNodeType NodeType => BlueprintNodeType.Branch; + public bool IsControlFlow => true; + + public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) + { + var condition = helper.GetInputValue(node, "Condition"); + return new FlowControlStatement + { + ControlType = FlowControlType.Branch, + ConditionExpression = condition, + SourceCode = $"Branch({condition}, \"\", \"\");", + LineNumber = 1 + }; + } + + public IEnumerable GetOutputArms(BlueprintNode node) + { + return + [ + new OutputArmDescriptor { PinName = "True", IsLoopback = false }, + new OutputArmDescriptor { PinName = "False", IsLoopback = false } + ]; + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/BreakNodeExportStrategy.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/BreakNodeExportStrategy.cs new file mode 100644 index 00000000..e9eca4c7 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/BreakNodeExportStrategy.cs @@ -0,0 +1,21 @@ +using KitX.Core.Contract.Workflow; + +namespace KitX.Core.Workflow.Blueprint.ExportStrategies; + +public class BreakNodeExportStrategy : INodeExportStrategy +{ + public BlueprintNodeType NodeType => BlueprintNodeType.Break; + public bool IsControlFlow => false; + + public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) + { + return new FlowControlStatement + { + ControlType = FlowControlType.Break, + SourceCode = "Break();", + LineNumber = 1 + }; + } + + public IEnumerable GetOutputArms(BlueprintNode node) => []; +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/CallHelperNodeExportStrategy.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/CallHelperNodeExportStrategy.cs new file mode 100644 index 00000000..4b828601 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/CallHelperNodeExportStrategy.cs @@ -0,0 +1,23 @@ +using KitX.Core.Contract.Workflow; + +namespace KitX.Core.Workflow.Blueprint.ExportStrategies; + +public class CallHelperNodeExportStrategy : INodeExportStrategy +{ + public BlueprintNodeType NodeType => BlueprintNodeType.CallHelper; + public bool IsControlFlow => false; + + public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) + { + if (node is not CallHelperNode callHelper) return null; + var helperArgs = helper.GetInputArgs(callHelper); + return new ExpressionStatement + { + Expression = $"{callHelper.HelperFunctionName}({helperArgs});", + SourceCode = $"{callHelper.HelperFunctionName}({helperArgs});", + LineNumber = 1 + }; + } + + public IEnumerable GetOutputArms(BlueprintNode node) => []; +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/CallNodeExportStrategy.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/CallNodeExportStrategy.cs new file mode 100644 index 00000000..a6be4c5b --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/CallNodeExportStrategy.cs @@ -0,0 +1,23 @@ +using KitX.Core.Contract.Workflow; + +namespace KitX.Core.Workflow.Blueprint.ExportStrategies; + +public class CallNodeExportStrategy : INodeExportStrategy +{ + public BlueprintNodeType NodeType => BlueprintNodeType.Call; + public bool IsControlFlow => false; + + public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) + { + if (node is not CallNode call) return null; + var callArgs = helper.GetInputArgs(call); + return new ExpressionStatement + { + Expression = $"{call.FunctionName}({callArgs});", + SourceCode = $"{call.FunctionName}({callArgs});", + LineNumber = 1 + }; + } + + public IEnumerable GetOutputArms(BlueprintNode node) => []; +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/LoopNodeExportStrategy.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/LoopNodeExportStrategy.cs new file mode 100644 index 00000000..e1285eff --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/LoopNodeExportStrategy.cs @@ -0,0 +1,30 @@ +using KitX.Core.Contract.Workflow; + +namespace KitX.Core.Workflow.Blueprint.ExportStrategies; + +public class LoopNodeExportStrategy : INodeExportStrategy +{ + public BlueprintNodeType NodeType => BlueprintNodeType.Loop; + public bool IsControlFlow => true; + + public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) + { + var condition = helper.GetInputValue(node, "Condition"); + return new FlowControlStatement + { + ControlType = FlowControlType.Loop, + ConditionExpression = condition, + SourceCode = $"Loop({condition}, \"\", \"\");", + LineNumber = 1 + }; + } + + public IEnumerable GetOutputArms(BlueprintNode node) + { + return + [ + new OutputArmDescriptor { PinName = "LoopBody", IsLoopback = true }, + new OutputArmDescriptor { PinName = "LoopEnd", IsLoopback = false } + ]; + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/PauseNodeExportStrategy.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/PauseNodeExportStrategy.cs new file mode 100644 index 00000000..12f04c06 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/PauseNodeExportStrategy.cs @@ -0,0 +1,22 @@ +using KitX.Core.Contract.Workflow; + +namespace KitX.Core.Workflow.Blueprint.ExportStrategies; + +public class PauseNodeExportStrategy : INodeExportStrategy +{ + public BlueprintNodeType NodeType => BlueprintNodeType.Pause; + public bool IsControlFlow => false; + + public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) + { + var ms = helper.GetInputValue(node, "Milliseconds"); + return new ExpressionStatement + { + Expression = $"Pause({ms});", + SourceCode = $"Pause({ms});", + LineNumber = 1 + }; + } + + public IEnumerable GetOutputArms(BlueprintNode node) => []; +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/PrintNodeExportStrategy.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/PrintNodeExportStrategy.cs new file mode 100644 index 00000000..84c48980 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/PrintNodeExportStrategy.cs @@ -0,0 +1,22 @@ +using KitX.Core.Contract.Workflow; + +namespace KitX.Core.Workflow.Blueprint.ExportStrategies; + +public class PrintNodeExportStrategy : INodeExportStrategy +{ + public BlueprintNodeType NodeType => BlueprintNodeType.Print; + public bool IsControlFlow => false; + + public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) + { + var value = helper.GetInputValue(node, "Value"); + return new ExpressionStatement + { + Expression = $"Print({value});", + SourceCode = $"Print({value});", + LineNumber = 1 + }; + } + + public IEnumerable GetOutputArms(BlueprintNode node) => []; +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/INodeExportStrategy.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/INodeExportStrategy.cs new file mode 100644 index 00000000..7436704b --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/INodeExportStrategy.cs @@ -0,0 +1,75 @@ +using System.Collections.Generic; +using KitX.Core.Contract.Workflow; + +namespace KitX.Core.Workflow.Blueprint; + +/// +/// Helper service providing data resolution utilities for node export strategies. +/// Implemented by BlueprintToBlockScriptConverter. +/// +public interface INodeExportHelper +{ + /// + /// Resolves the input value for a pin by tracing data connections. + /// Returns ConstName for const references, PubVarName for pubvar references, + /// or the pin's default value. + /// + string GetInputValue(BlueprintNode node, string pinName); + + /// + /// Resolves all non-Exec input arguments for a node, returning them as a comma-separated string. + /// + string GetInputArgs(BlueprintNode node); + + /// + /// The blueprint being converted. + /// + Contract.Workflow.Blueprint Blueprint { get; } +} + +/// +/// Strategy for converting a specific node type to a BlockScript statement. +/// Each node type that participates in reverse conversion provides an implementation, +/// eliminating the need for switch-based dispatch in the converter. +/// +public interface INodeExportStrategy +{ + /// + /// The node type this strategy handles. + /// + BlueprintNodeType NodeType { get; } + + /// + /// Whether this node type represents a control flow construct (Branch, Loop, etc.). + /// Used by the converter to determine main flow termination and sub-graph processing. + /// + bool IsControlFlow { get; } + + /// + /// Converts the node to a BlockScript statement, or null if the node should be skipped. + /// + BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper); + + /// + /// For control flow nodes: returns the output arm configuration. + /// Each arm defines an output pin name and whether it represents a loopback. + /// Non-control-flow strategies return an empty collection. + /// + IEnumerable GetOutputArms(BlueprintNode node); +} + +/// +/// Describes a single output arm of a control flow node (e.g., Branch's True/False, Loop's LoopBody/LoopEnd). +/// +public struct OutputArmDescriptor +{ + /// + /// The output pin name (e.g., "True", "False", "LoopBody", "LoopEnd") + /// + public string PinName { get; set; } + + /// + /// Whether this arm loops back to a parent node (LoopBody loops back to the Loop node) + /// + public bool IsLoopback { get; set; } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/LayoutService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/LayoutService.cs index 2fbbb6b8..ec3a7502 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/LayoutService.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/LayoutService.cs @@ -141,22 +141,23 @@ public void LayoutNodes(Contract.Workflow.Blueprint blueprint) return linear; } - if (targets.Count == 2) + if (targets.Count >= 2) { - // Fork: Branch (True/False) or Loop (LoopBody/LoopEnd) + // Fork: Branch (True/False), Loop (LoopBody/LoopEnd), or any N-way control flow placed.Add(nodeId); var node = blueprint.GetNodeById(nodeId); - Log.Debug("[Layout] Fork node: {Name} ({Type}) → [{Pin1}, {Pin2}]", - node?.Name, node?.NodeType, targets[0].PinName, targets[1].PinName); + Log.Debug("[Layout] Fork node: {Name} ({Type}) → {BranchCount} branches [{Pins}]", + node?.Name, node?.NodeType, targets.Count, + string.Join(", ", targets.Select(t => t.PinName))); - // First target → UpperBranch (True / LoopBody) - var upper = BuildRegionTree(targets[0].TargetId, execMap, blueprint, visited, placed); - - // Second target → LowerBranch (False / LoopEnd) - var lower = BuildRegionTree(targets[1].TargetId, execMap, blueprint, visited, placed); + var branches = new List(); + foreach (var target in targets) + { + branches.Add(BuildRegionTree(target.TargetId, execMap, blueprint, visited, placed)); + } - return new ForkRegion(nodeId, upper, lower); + return new ForkRegion(nodeId, branches); } // Fallback: 3+ exec outputs (treat as linear) @@ -312,20 +313,23 @@ public override void Arrange(double x, double y, Contract.Workflow.Blueprint bp) } /// - /// Fork region: a fork node (Branch/Loop) with upper and lower sub-branches + /// Fork region: a fork node (Branch/Loop/etc.) with N sub-branches /// arranged vertically, indented to the right. + /// Supports any number of execution output arms (2 for Branch/Loop, N for future nodes). /// private class ForkRegion : LayoutRegion { public string ForkNodeId; - public LayoutRegion? UpperBranch; - public LayoutRegion? LowerBranch; + public List Branches; + + // Backward-compatible convenience properties for 2-branch case + public LayoutRegion? UpperBranch => Branches.Count > 0 ? Branches[0] : null; + public LayoutRegion? LowerBranch => Branches.Count > 1 ? Branches[1] : null; - public ForkRegion(string forkNodeId, LayoutRegion? upper, LayoutRegion? lower) + public ForkRegion(string forkNodeId, List branches) { ForkNodeId = forkNodeId; - UpperBranch = upper; - LowerBranch = lower; + Branches = branches; } public override void Measure(double availableWidth = MaxRowWidth) @@ -333,18 +337,32 @@ public override void Measure(double availableWidth = MaxRowWidth) // Branches are indented to the right of the fork node double branchAvailableWidth = Math.Max(NodeWidth, availableWidth - NodeWidth - ForkHGap); - UpperBranch?.Measure(branchAvailableWidth); - LowerBranch?.Measure(branchAvailableWidth); + // Measure all branches + foreach (var branch in Branches) + branch?.Measure(branchAvailableWidth); - double upperW = UpperBranch?.MeasuredWidth ?? 0; - double lowerW = LowerBranch?.MeasuredWidth ?? 0; - double upperH = UpperBranch?.MeasuredHeight ?? 0; - double lowerH = LowerBranch?.MeasuredHeight ?? 0; + // Width: fork node + gap + max branch width + double maxBranchWidth = Branches + .Where(b => b != null) + .Select(b => b!.MeasuredWidth) + .DefaultIfEmpty(0) + .Max(); - // Width: fork node + gap + max of branches (includes indentation) - MeasuredWidth = NodeWidth + ForkHGap + Math.Max(upperW, lowerW); - // Height: fork node + gap + both branches stacked vertically - MeasuredHeight = NodeHeight + ForkVGap + upperH + (lowerH > 0 ? VSpacing + lowerH : 0); + MeasuredWidth = NodeWidth + ForkHGap + maxBranchWidth; + + // Height: fork node + gap + all branches stacked vertically with spacing + double totalBranchHeight = 0; + foreach (var branch in Branches) + { + if (branch != null && branch.MeasuredHeight > 0) + { + if (totalBranchHeight > 0) + totalBranchHeight += VSpacing; + totalBranchHeight += branch.MeasuredHeight; + } + } + + MeasuredHeight = NodeHeight + ForkVGap + totalBranchHeight; } public override void Arrange(double x, double y, Contract.Workflow.Blueprint bp) @@ -359,19 +377,17 @@ public override void Arrange(double x, double y, Contract.Workflow.Blueprint bp) // Indent branches to the right of the fork node double branchX = x + NodeWidth + ForkHGap; - double upperY = y + NodeHeight + ForkVGap; + double currentY = y + NodeHeight + ForkVGap; - // Arrange upper branch (True / LoopBody) - if (UpperBranch != null) + // Arrange all branches stacked vertically + foreach (var branch in Branches) { - UpperBranch.Arrange(branchX, upperY, bp); - } - - // Arrange lower branch below upper branch (False / LoopEnd) - if (LowerBranch != null) - { - double lowerY = upperY + (UpperBranch?.MeasuredHeight ?? 0) + VSpacing; - LowerBranch.Arrange(branchX, lowerY, bp); + if (branch != null) + { + branch.Arrange(branchX, currentY, bp); + if (branch.MeasuredHeight > 0) + currentY += branch.MeasuredHeight + VSpacing; + } } } } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeCreationService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeCreationService.cs deleted file mode 100644 index bb4943d6..00000000 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeCreationService.cs +++ /dev/null @@ -1,162 +0,0 @@ -using System; -using KitX.Core.Contract.Workflow; -using Serilog; - -namespace KitX.Core.Workflow.Blueprint; - -/// -/// Implementation of node creation service -/// -public class NodeCreationService : INodeCreationService -{ - /// - /// Creates a constant node - /// - public ConstNode CreateConstNode(string name, string type, string? defaultValue) - { - var constNode = new ConstNode - { - ConstName = name, - ConstType = type, - ConstValue = defaultValue ?? GetDefaultValueForType(type) - }; - Log.Debug("Created ConstNode: Name={ConstName}, Id={NodeId}", constNode.Name, constNode.Id); - return constNode; - } - - /// - /// Creates an entry node at the specified position - /// - public EntryNode CreateEntryNode(double x, double y) - { - var entryNode = new EntryNode - { - X = x, - Y = y - }; - Log.Debug("Created EntryNode: Name={NodeName}, Id={NodeId}", entryNode.Name, entryNode.Id); - return entryNode; - } - - /// - /// Creates a get variable node - /// - public GetNode CreateGetNode(string varName) - { - var getNode = new GetNode - { - VarName = varName, - Name = $"Get:{varName}" - }; - Log.Debug("Created GetNode: VarName={VarName}, Id={NodeId}", varName, getNode.Id); - return getNode; - } - - /// - /// Creates a set variable node - /// - public SetNode CreateSetNode(string varName) - { - var setNode = new SetNode - { - VarName = varName, - Name = $"Set:{varName}" - }; - Log.Debug("Created SetNode: VarName={VarName}, Id={NodeId}", varName, setNode.Id); - return setNode; - } - - /// - /// Creates a print node - /// - public PrintNode CreatePrintNode() - { - var printNode = new PrintNode(); - Log.Debug("Created PrintNode: Id={NodeId}", printNode.Id); - return printNode; - } - - /// - /// Creates a pause node - /// - public PauseNode CreatePauseNode() - { - var pauseNode = new PauseNode(); - Log.Debug("Created PauseNode: Id={NodeId}", pauseNode.Id); - return pauseNode; - } - - /// - /// Creates a call function node - /// - public CallNode CreateCallNode(string functionName) - { - var callNode = new CallNode - { - FunctionName = functionName, - Name = $"Call:{functionName}" - }; - Log.Debug("Created CallNode: FunctionName={FunctionName}, Id={NodeId}", functionName, callNode.Id); - return callNode; - } - - /// - /// Creates a call helper function node - /// - public CallHelperNode CreateCallHelperNode(string helperFunctionName) - { - var callHelperNode = new CallHelperNode - { - HelperFunctionName = helperFunctionName, - Name = $"Helper:{helperFunctionName}" - }; - Log.Debug("Created CallHelperNode: HelperFunctionName={HelperFunctionName}, Id={NodeId}", helperFunctionName, callHelperNode.Id); - return callHelperNode; - } - - /// - /// Creates a branch node - /// - public BranchNode CreateBranchNode() - { - var branchNode = new BranchNode(); - Log.Debug("Created BranchNode: Id={NodeId}", branchNode.Id); - return branchNode; - } - - /// - /// Creates a loop node - /// - public LoopNode CreateLoopNode() - { - var loopNode = new LoopNode(); - Log.Debug("Created LoopNode: Id={NodeId}", loopNode.Id); - return loopNode; - } - - /// - /// Creates a break node - /// - public BreakNode CreateBreakNode() - { - var breakNode = new BreakNode(); - Log.Debug("Created BreakNode: Id={NodeId}", breakNode.Id); - return breakNode; - } - - /// - /// Gets default value for a given type - /// - private static string GetDefaultValueForType(string type) - { - return type.ToLower() switch - { - "int" or "integer" or "long" or "short" or "byte" => "0", - "float" or "double" or "decimal" => "0", - "bool" or "boolean" => "false", - "string" => "", - "char" => "\0", - _ => "" - }; - } -} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeRegistry.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeRegistry.cs new file mode 100644 index 00000000..87a41b14 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeRegistry.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; +using KitX.Core.Contract.Workflow; +using Serilog; + +namespace KitX.Core.Workflow.Blueprint; + +/// +/// Unified registry for node type creation and metadata. +/// Each node type is self-describing via GetDescriptor(), eliminating +/// the need for external switch statements when adding new node types. +/// +public class NodeRegistry : INodeRegistry +{ + private readonly Dictionary _typeMap; + private readonly Dictionary _descriptorCache; + + public NodeRegistry() + { + // Map each BlueprintNodeType enum value to its concrete class + _typeMap = new Dictionary + { + [BlueprintNodeType.Entry] = typeof(EntryNode), + [BlueprintNodeType.Branch] = typeof(BranchNode), + [BlueprintNodeType.Loop] = typeof(LoopNode), + [BlueprintNodeType.Break] = typeof(BreakNode), + [BlueprintNodeType.Const] = typeof(ConstNode), + [BlueprintNodeType.Call] = typeof(CallNode), + [BlueprintNodeType.CallHelper] = typeof(CallHelperNode), + [BlueprintNodeType.Get] = typeof(GetNode), + [BlueprintNodeType.Set] = typeof(SetNode), + [BlueprintNodeType.Print] = typeof(PrintNode), + [BlueprintNodeType.Pause] = typeof(PauseNode), + }; + + // Pre-cache descriptors from each node type + _descriptorCache = new Dictionary(); + foreach (var kvp in _typeMap) + { + var instance = (BlueprintNode)Activator.CreateInstance(kvp.Value)!; + _descriptorCache[kvp.Key] = instance.GetDescriptor(); + } + + Log.Information("NodeRegistry initialized with {Count} node types", _typeMap.Count); + } + + /// + public BlueprintNode Create(BlueprintNodeType type) + { + if (!_typeMap.TryGetValue(type, out var nodeType)) + throw new ArgumentException($"Unknown node type: {type}"); + + return (BlueprintNode)Activator.CreateInstance(nodeType)!; + } + + /// + public NodeDescriptor GetDescriptor(BlueprintNodeType type) + { + if (!_descriptorCache.TryGetValue(type, out var descriptor)) + throw new ArgumentException($"No descriptor for node type: {type}"); + + return descriptor; + } + + /// + public IReadOnlySet RegisteredTypes => _typeMap.Keys.ToHashSet(); +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeTemplateProvider.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeTemplateProvider.cs deleted file mode 100644 index ed1064e0..00000000 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeTemplateProvider.cs +++ /dev/null @@ -1,244 +0,0 @@ -using System; -using System.Collections.Generic; -using KitX.Core.Contract.Workflow; - -namespace KitX.Core.Workflow.Blueprint; - -/// -/// 节点模板提供者 - 统一所有节点的尺寸和引脚位置 -/// -public class NodeTemplateProvider : INodeTemplateProvider -{ - private readonly Dictionary _templates; - - public NodeTemplateProvider() - { - _templates = new Dictionary - { - [BlueprintNodeType.Entry] = new NodeTemplate - { - NodeType = BlueprintNodeType.Entry, - Name = "Entry", - Width = 120, - Height = 60, - OutputPins = - [ - new PinTemplate { Name = "Exec", Type = PinType.Execution, RelativeY = 30 } - ] - }, - - [BlueprintNodeType.Branch] = new NodeTemplate - { - NodeType = BlueprintNodeType.Branch, - Name = "Branch", - Width = 120, - Height = 80, - InputPins = - [ - new PinTemplate { Name = "Exec", Type = PinType.Execution, RelativeY = 30 }, - new PinTemplate { Name = "Condition", Type = PinType.Boolean, RelativeY = 50 } - ], - OutputPins = - [ - new PinTemplate { Name = "True", Type = PinType.Execution, RelativeY = 30 }, - new PinTemplate { Name = "False", Type = PinType.Execution, RelativeY = 50 } - ] - }, - - [BlueprintNodeType.Loop] = new NodeTemplate - { - NodeType = BlueprintNodeType.Loop, - Name = "Loop", - Width = 120, - Height = 80, - InputPins = - [ - new PinTemplate { Name = "Exec", Type = PinType.Execution, RelativeY = 30 }, - new PinTemplate { Name = "Condition", Type = PinType.Boolean, RelativeY = 50 } - ], - OutputPins = - [ - new PinTemplate { Name = "LoopBody", Type = PinType.Execution, RelativeY = 30 }, - new PinTemplate { Name = "LoopEnd", Type = PinType.Execution, RelativeY = 50 } - ] - }, - - [BlueprintNodeType.Break] = new NodeTemplate - { - NodeType = BlueprintNodeType.Break, - Name = "Break", - Width = 100, - Height = 40, - InputPins = - [ - new PinTemplate { Name = "Exec", Type = PinType.Execution, RelativeY = 20 } - ] - }, - - [BlueprintNodeType.Const] = new NodeTemplate - { - NodeType = BlueprintNodeType.Const, - Name = "Const", - Width = 120, - Height = 50, - OutputPins = - [ - new PinTemplate { Name = "Value", Type = PinType.Any, RelativeY = 25 } - ] - }, - - [BlueprintNodeType.Call] = new NodeTemplate - { - NodeType = BlueprintNodeType.Call, - Name = "Call", - Width = 140, - Height = 60, - InputPins = - [ - new PinTemplate { Name = "Exec", Type = PinType.Execution, RelativeY = 20 } - ], - OutputPins = - [ - new PinTemplate { Name = "Exec", Type = PinType.Execution, RelativeY = 20 }, - new PinTemplate { Name = "Return", Type = PinType.Any, RelativeY = 40 } - ] - }, - - [BlueprintNodeType.CallHelper] = new NodeTemplate - { - NodeType = BlueprintNodeType.CallHelper, - Name = "CallHelper", - Width = 130, - Height = 50, - InputPins = - [ - new PinTemplate { Name = "Exec", Type = PinType.Execution, RelativeY = 25 } - ], - OutputPins = - [ - new PinTemplate { Name = "Exec", Type = PinType.Execution, RelativeY = 25 }, - new PinTemplate { Name = "Return", Type = PinType.Any, RelativeY = 40 } - ] - }, - - [BlueprintNodeType.Print] = new NodeTemplate - { - NodeType = BlueprintNodeType.Print, - Name = "Print", - Width = 100, - Height = 50, - InputPins = - [ - new PinTemplate { Name = "Exec", Type = PinType.Execution, RelativeY = 20 }, - new PinTemplate { Name = "Value", Type = PinType.Any, RelativeY = 35 } - ], - OutputPins = - [ - new PinTemplate { Name = "Exec", Type = PinType.Execution, RelativeY = 25 } - ] - }, - - [BlueprintNodeType.Pause] = new NodeTemplate - { - NodeType = BlueprintNodeType.Pause, - Name = "Pause", - Width = 100, - Height = 50, - InputPins = - [ - new PinTemplate { Name = "Exec", Type = PinType.Execution, RelativeY = 20 }, - new PinTemplate { Name = "Milliseconds", Type = PinType.Integer, RelativeY = 35 } - ], - OutputPins = - [ - new PinTemplate { Name = "Exec", Type = PinType.Execution, RelativeY = 25 } - ] - }, - - [BlueprintNodeType.Get] = new NodeTemplate - { - NodeType = BlueprintNodeType.Get, - Name = "Get", - Width = 120, - Height = 60, - InputPins = - [ - new PinTemplate { Name = "Exec", Type = PinType.Execution, RelativeY = 20 } - ], - OutputPins = - [ - new PinTemplate { Name = "Exec", Type = PinType.Execution, RelativeY = 20 }, - new PinTemplate { Name = "Value", Type = PinType.Any, RelativeY = 40 } - ] - }, - - [BlueprintNodeType.Set] = new NodeTemplate - { - NodeType = BlueprintNodeType.Set, - Name = "Set", - Width = 120, - Height = 60, - InputPins = - [ - new PinTemplate { Name = "Exec", Type = PinType.Execution, RelativeY = 20 }, - new PinTemplate { Name = "Value", Type = PinType.Any, RelativeY = 40 } - ], - OutputPins = - [ - new PinTemplate { Name = "Exec", Type = PinType.Execution, RelativeY = 20 } - ] - } - }; - } - - public BlueprintNode CreateNode(BlueprintNodeType type) => type switch - { - BlueprintNodeType.Entry => new EntryNode(), - BlueprintNodeType.Branch => new BranchNode(), - BlueprintNodeType.Loop => new LoopNode(), - BlueprintNodeType.Break => new BreakNode(), - BlueprintNodeType.Const => new ConstNode(), - BlueprintNodeType.Call => new CallNode(), - BlueprintNodeType.CallHelper => new CallHelperNode(), - BlueprintNodeType.Print => new PrintNode(), - BlueprintNodeType.Pause => new PauseNode(), - BlueprintNodeType.Get => new GetNode(), - BlueprintNodeType.Set => new SetNode(), - _ => throw new ArgumentException($"Unknown node type: {type}") - }; - - public IReadOnlyDictionary GetTemplates() => _templates; - - public (double Width, double Height) GetNodeSize(BlueprintNodeType type) - { - if (_templates.TryGetValue(type, out var template)) - return (template.Width, template.Height); - return (120, 60); - } - - public double GetInputPinY(BlueprintNodeType nodeType, string pinName) - { - if (_templates.TryGetValue(nodeType, out var template)) - { - foreach (var pin in template.InputPins) - { - if (pin.Name == pinName) - return pin.RelativeY; - } - } - return 30; - } - - public double GetOutputPinY(BlueprintNodeType nodeType, string pinName) - { - if (_templates.TryGetValue(nodeType, out var template)) - { - foreach (var pin in template.OutputPins) - { - if (pin.Name == pinName) - return pin.RelativeY; - } - } - return 30; - } -} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/NodeBuilder.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/NodeBuilder.cs index 52855549..cc35a7e5 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/NodeBuilder.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/NodeBuilder.cs @@ -12,7 +12,7 @@ namespace KitX.Core.Workflow.Blueprint.Pipeline; /// public class NodeBuilder { - private readonly INodeCreationService _factory; + private readonly INodeRegistry _registry; private readonly List _helpers; private readonly HashSet _helperNames; @@ -24,9 +24,9 @@ public class NodeBuilder private readonly Dictionary _blockLastStmtId = new(); private readonly Dictionary _blockEndsWithFlowCtrl = new(); - public NodeBuilder(INodeCreationService factory, List helpers) + public NodeBuilder(INodeRegistry registry, List helpers) { - _factory = factory; + _registry = registry; _helpers = helpers; _helperNames = new HashSet(helpers.Select(h => h.Name)); } @@ -34,7 +34,9 @@ public NodeBuilder(INodeCreationService factory, List helpers) public void Build(FormattedBlockScript script, PipelineContext context) { // Create EntryNode - var entry = _factory.CreateEntryNode(0, 0); + var entry = (EntryNode)_registry.Create(BlueprintNodeType.Entry); + entry.X = 0; + entry.Y = 0; context.EntryNode = entry; context.AllNodes.Add(entry); context.NodeByStatementId["__entry__"] = entry; @@ -106,27 +108,35 @@ or FormattedStatementKind.LoopBodyEnd return ProcessCallOrAssignment(stmt, context, ref prevNode, ref prevStmtId); case FormattedStatementKind.Print: - return ChainNewNode(_factory.CreatePrintNode(), stmt, context, ref prevNode, ref prevStmtId); + return ChainNewNode(_registry.Create(BlueprintNodeType.Print), stmt, context, ref prevNode, ref prevStmtId); case FormattedStatementKind.Set: - return ChainNewNode(_factory.CreateSetNode(stmt.SetVarName ?? ""), stmt, context, ref prevNode, ref prevStmtId); + { + var node = (SetNode)_registry.Create(BlueprintNodeType.Set); + node.VarName = stmt.SetVarName ?? ""; + return ChainNewNode(node, stmt, context, ref prevNode, ref prevStmtId); + } case FormattedStatementKind.Get: - return ChainNewNode(_factory.CreateGetNode(stmt.GetVarName ?? ""), stmt, context, ref prevNode, ref prevStmtId); + { + var node = (GetNode)_registry.Create(BlueprintNodeType.Get); + node.VarName = stmt.GetVarName ?? ""; + return ChainNewNode(node, stmt, context, ref prevNode, ref prevStmtId); + } case FormattedStatementKind.Pause: - return ChainNewNode(_factory.CreatePauseNode(), stmt, context, ref prevNode, ref prevStmtId); + return ChainNewNode(_registry.Create(BlueprintNodeType.Pause), stmt, context, ref prevNode, ref prevStmtId); case FormattedStatementKind.Branch: { - var node = ChainNewNode(_factory.CreateBranchNode(), stmt, context, ref prevNode, ref prevStmtId); + var node = ChainNewNode(_registry.Create(BlueprintNodeType.Branch), stmt, context, ref prevNode, ref prevStmtId); _branchDefs.Add((stmt.StatementId, stmt.TrueBlockName, stmt.FalseBlockName)); return node; } case FormattedStatementKind.Loop: { - var node = ChainNewNode(_factory.CreateLoopNode(), stmt, context, ref prevNode, ref prevStmtId); + var node = ChainNewNode(_registry.Create(BlueprintNodeType.Loop), stmt, context, ref prevNode, ref prevStmtId); _loopDefs.Add((stmt.StatementId, stmt.TrueBlockName, stmt.FalseBlockName, blockName)); context.LoopNodesByParent[blockName] = (LoopNode)node!; return node; @@ -138,7 +148,7 @@ or FormattedStatementKind.LoopBodyEnd return null; case FormattedStatementKind.Break: - return ChainNewNode(_factory.CreateBreakNode(), stmt, context, ref prevNode, ref prevStmtId); + return ChainNewNode(_registry.Create(BlueprintNodeType.Break), stmt, context, ref prevNode, ref prevStmtId); default: return null; @@ -193,14 +203,25 @@ or FormattedStatementKind.LoopBodyEnd { // Get assignment → create GetNode var varName = stmt.Arguments?.Count > 0 ? stmt.Arguments[0].Trim('"') : ""; - mainNode = _factory.CreateGetNode(varName); + var getNode = (GetNode)_registry.Create(BlueprintNodeType.Get); + getNode.VarName = varName; + mainNode = getNode; } else { var isHelper = _helperNames.Contains(stmt.FunctionName); - mainNode = isHelper - ? _factory.CreateCallHelperNode(stmt.FunctionName!) - : _factory.CreateCallNode(stmt.FunctionName!); + if (isHelper) + { + var helperNode = (CallHelperNode)_registry.Create(BlueprintNodeType.CallHelper); + helperNode.HelperFunctionName = stmt.FunctionName!; + mainNode = helperNode; + } + else + { + var callNode = (CallNode)_registry.Create(BlueprintNodeType.Call); + callNode.FunctionName = stmt.FunctionName!; + mainNode = callNode; + } // Add parameter pins based on helper definition or argument count AddParamPins(mainNode, stmt.FunctionName!, stmt.Arguments?.Count ?? 0); diff --git a/KitX Clients/KitX Dashboard b/KitX Clients/KitX Dashboard index ae41aa0d..18dd941f 160000 --- a/KitX Clients/KitX Dashboard +++ b/KitX Clients/KitX Dashboard @@ -1 +1 @@ -Subproject commit ae41aa0d088a88991cd3a3f1be77b173536f1978 +Subproject commit 18dd941f2575d58d7cc2c49b9fac50e5e15bea9b diff --git a/KitX Standard b/KitX Standard index 56934260..46442b16 160000 --- a/KitX Standard +++ b/KitX Standard @@ -1 +1 @@ -Subproject commit 56934260baa68c2116fd96bc01236b6388e08eff +Subproject commit 46442b16d697b298c57e8ae551c9467b292596f7 From 0261163001cdafc3ef78c7d5c0990de5006cadbd Mon Sep 17 00:00:00 2001 From: StarInk Date: Mon, 6 Apr 2026 07:40:57 +0200 Subject: [PATCH 030/110] =?UTF-8?q?=F0=9F=92=BE=20Feat(LayoutService):=20?= =?UTF-8?q?=E8=B0=83=E6=95=B4=E5=88=86=E5=8F=89=E5=8C=BA=E5=9F=9F=E7=9A=84?= =?UTF-8?q?=E5=9E=82=E7=9B=B4=E9=97=B4=E8=B7=9D=E5=92=8C=E5=B8=83=E5=B1=80?= =?UTF-8?q?=E9=80=BB=E8=BE=91=E4=BB=A5=E6=94=AF=E6=8C=81=E5=AF=B9=E7=A7=B0?= =?UTF-8?q?=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Workflow/Blueprint/LayoutService.cs | 96 +++++++++++++++---- KitX Clients/KitX Dashboard | 2 +- 2 files changed, 77 insertions(+), 21 deletions(-) diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/LayoutService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/LayoutService.cs index ec3a7502..e638949e 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/LayoutService.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/LayoutService.cs @@ -17,7 +17,7 @@ public class LayoutService : ILayoutService // Layout constants private const double HSpacing = 220; private const double VSpacing = 130; - private const double ForkVGap = 200; + private const double ForkVGap = 60; private const double ForkHGap = 40; private const double MaxRowWidth = 1200; private const double XOffset = 50; @@ -326,12 +326,29 @@ private class ForkRegion : LayoutRegion public LayoutRegion? UpperBranch => Branches.Count > 0 ? Branches[0] : null; public LayoutRegion? LowerBranch => Branches.Count > 1 ? Branches[1] : null; + // Symmetric layout: upper / middle / lower branch groups + private double _upperBranchHeight; + private double _middleBranchHeight; + public ForkRegion(string forkNodeId, List branches) { ForkNodeId = forkNodeId; Branches = branches; } + /// + /// Determines layout direction for a branch by its index. + /// -1 = upper-right, 0 = straight-right (same Y as fork), 1 = lower-right + /// Rule: compare (index + 1) with (total + 1) / 2.0 + /// + private static int GetBranchDirection(int index, int total) + { + double mid = (total + 1) / 2.0; + if (index + 1 < mid) return -1; + if (index + 1 == mid) return 0; + return 1; + } + public override void Measure(double availableWidth = MaxRowWidth) { // Branches are indented to the right of the fork node @@ -341,7 +358,7 @@ public override void Measure(double availableWidth = MaxRowWidth) foreach (var branch in Branches) branch?.Measure(branchAvailableWidth); - // Width: fork node + gap + max branch width + // Width: fork node + gap + max branch width (unchanged) double maxBranchWidth = Branches .Where(b => b != null) .Select(b => b!.MeasuredWidth) @@ -350,43 +367,82 @@ public override void Measure(double availableWidth = MaxRowWidth) MeasuredWidth = NodeWidth + ForkHGap + maxBranchWidth; - // Height: fork node + gap + all branches stacked vertically with spacing - double totalBranchHeight = 0; - foreach (var branch in Branches) + // Height: symmetric layout — group branches by direction + _upperBranchHeight = 0; + _middleBranchHeight = 0; + double lowerBranchHeight = 0; + + for (int i = 0; i < Branches.Count; i++) { - if (branch != null && branch.MeasuredHeight > 0) + var branch = Branches[i]; + if (branch == null || branch.MeasuredHeight <= 0) continue; + + int dir = GetBranchDirection(i, Branches.Count); + switch (dir) { - if (totalBranchHeight > 0) - totalBranchHeight += VSpacing; - totalBranchHeight += branch.MeasuredHeight; + case -1: // upper + if (_upperBranchHeight > 0) _upperBranchHeight += VSpacing; + _upperBranchHeight += branch.MeasuredHeight; + break; + case 0: // middle (straight-right) + _middleBranchHeight = branch.MeasuredHeight; + break; + case 1: // lower + if (lowerBranchHeight > 0) lowerBranchHeight += VSpacing; + lowerBranchHeight += branch.MeasuredHeight; + break; } } - MeasuredHeight = NodeHeight + ForkVGap + totalBranchHeight; + double middleAndForkHeight = Math.Max(NodeHeight, _middleBranchHeight); + + MeasuredHeight = _upperBranchHeight + + (_upperBranchHeight > 0 ? ForkVGap : 0) + + middleAndForkHeight + + (lowerBranchHeight > 0 ? ForkVGap : 0) + + lowerBranchHeight; } public override void Arrange(double x, double y, Contract.Workflow.Blueprint bp) { + double branchX = x + NodeWidth + ForkHGap; + + // Fork node Y: offset down by upper branch height + double forkY = y + _upperBranchHeight + + (_upperBranchHeight > 0 ? ForkVGap : 0); + // Place fork node var forkNode = bp.GetNodeById(ForkNodeId); if (forkNode != null) { forkNode.X = x; - forkNode.Y = y; + forkNode.Y = forkY; } - // Indent branches to the right of the fork node - double branchX = x + NodeWidth + ForkHGap; - double currentY = y + NodeHeight + ForkVGap; + // Arrange branches by direction + double currentUpperY = y; + double middleAndForkHeight = Math.Max(NodeHeight, _middleBranchHeight); + double currentLowerY = forkY + middleAndForkHeight + ForkVGap; - // Arrange all branches stacked vertically - foreach (var branch in Branches) + for (int i = 0; i < Branches.Count; i++) { - if (branch != null) + var branch = Branches[i]; + if (branch == null || branch.MeasuredHeight <= 0) continue; + + int dir = GetBranchDirection(i, Branches.Count); + switch (dir) { - branch.Arrange(branchX, currentY, bp); - if (branch.MeasuredHeight > 0) - currentY += branch.MeasuredHeight + VSpacing; + case -1: // upper-right + branch.Arrange(branchX, currentUpperY, bp); + currentUpperY += branch.MeasuredHeight + VSpacing; + break; + case 0: // straight-right (same Y as fork) + branch.Arrange(branchX, forkY, bp); + break; + case 1: // lower-right + branch.Arrange(branchX, currentLowerY, bp); + currentLowerY += branch.MeasuredHeight + VSpacing; + break; } } } diff --git a/KitX Clients/KitX Dashboard b/KitX Clients/KitX Dashboard index 18dd941f..985987a5 160000 --- a/KitX Clients/KitX Dashboard +++ b/KitX Clients/KitX Dashboard @@ -1 +1 @@ -Subproject commit 18dd941f2575d58d7cc2c49b9fac50e5e15bea9b +Subproject commit 985987a5e62f2a3565fc4bb4b7a292f994499270 From 767b54b3d09335a7eef92b57d8bb31d490bc30f7 Mon Sep 17 00:00:00 2001 From: StarInk Date: Tue, 7 Apr 2026 03:09:05 +0200 Subject: [PATCH 031/110] =?UTF-8?q?=F0=9F=93=9D=20Chore:=20Update=20subpro?= =?UTF-8?q?ject=20commit=20for=20KitX=20Dashboard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- KitX Clients/KitX Dashboard | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/KitX Clients/KitX Dashboard b/KitX Clients/KitX Dashboard index 985987a5..2c2cb3b1 160000 --- a/KitX Clients/KitX Dashboard +++ b/KitX Clients/KitX Dashboard @@ -1 +1 @@ -Subproject commit 985987a5e62f2a3565fc4bb4b7a292f994499270 +Subproject commit 2c2cb3b1adf1a919c4b62a5a372ba0fa4714c00d From ae0a8b74266881c3de6afa2f58713dae248f4465 Mon Sep 17 00:00:00 2001 From: StarInk Date: Tue, 7 Apr 2026 11:44:30 +0200 Subject: [PATCH 032/110] =?UTF-8?q?=F0=9F=92=BE=20Feat(Workflow):=20Implem?= =?UTF-8?q?ent=20Blueprint=20service=20-=20bp2bs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../KitX.Core.BluePrint.Test/Program.cs | 208 +++ .../DI/CoreServiceCollectionExtensions.cs | 2 + .../BlueprintToBlockScriptConverter.cs | 1171 ++++++++++++++--- .../CallNodeExportStrategy.cs | 8 +- .../ExportStrategies/GetNodeExportStrategy.cs | 24 + .../ExportStrategies/SetNodeExportStrategy.cs | 24 + .../Blueprint/Pipeline/NodeBuilder.cs | 36 +- .../Blueprint/Pipeline/PipelineAssembler.cs | 139 ++ .../Blueprint/Pipeline/PipelineContext.cs | 7 + KitX Standard | 2 +- 10 files changed, 1399 insertions(+), 222 deletions(-) create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/GetNodeExportStrategy.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/SetNodeExportStrategy.cs diff --git a/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs b/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs index e533df1f..2a9a0d41 100644 --- a/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs +++ b/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs @@ -22,6 +22,7 @@ public static void Main(string[] args) var parser = sp.GetRequiredService(); var nodeRegistry = sp.GetRequiredService(); var layoutService = sp.GetRequiredService(); + var reverseConverter = sp.GetRequiredService(); Console.WriteLine("DI initialized.\n"); @@ -66,6 +67,27 @@ public static void Main(string[] args) Console.WriteLine("└──────────────────────────────────────────┘\n"); RunTest(converter, GetRawNestedScript(), helpers, "Test B"); + + // ── Test C: Reverse conversion (Blueprint → Expanded Script) ── + Console.WriteLine("\n┌──────────────────────────────────────────┐"); + Console.WriteLine("│ Test C: Blueprint → Expanded Script │"); + Console.WriteLine("└──────────────────────────────────────────┘\n"); + + RunReverseTest(converter, reverseConverter, GetRawNestedScript(), helpers, "Test C"); + + // ── Test D: Round-trip consistency test ── + Console.WriteLine("\n┌──────────────────────────────────────────┐"); + Console.WriteLine("│ Test D: Round-trip consistency │"); + Console.WriteLine("└──────────────────────────────────────────┘\n"); + + RunRoundTripTest(converter, reverseConverter, GetRawNestedScript(), helpers, "Test D"); + + // ── Test E: Manual Blueprint construction test ── + Console.WriteLine("\n┌──────────────────────────────────────────┐"); + Console.WriteLine("│ Test E: Manual Blueprint → Script │"); + Console.WriteLine("└──────────────────────────────────────────┘\n"); + + RunManualBlueprintTest(reverseConverter, "Test E"); } private static void RunTest(BlockScriptToBlueprintConverter converter, @@ -260,4 +282,190 @@ private static string GetRawNestedScript() => @"#ConstBlock #Block EndLogic Print(""示例工作流结束"");"; + + // ────────────────────────────────────────────── + // Test C: Reverse conversion test + // ────────────────────────────────────────────── + private static void RunReverseTest( + BlockScriptToBlueprintConverter forwardConverter, + IBlueprintToBlockScriptConverter reverseConverter, + string sourceCode, List helpers, string label) + { + try + { + // Step 1: Forward convert + var blueprint = forwardConverter.Convert(sourceCode, helpers); + Console.WriteLine($" [{label}] Forward conversion: {blueprint.Nodes.Count} nodes, {blueprint.Connections.Count} connections"); + + // Step 2: Reverse convert + var expandedScript = reverseConverter.Convert(blueprint); + + Console.WriteLine($"\n ── Expanded Script ({label}) ──"); + Console.WriteLine(expandedScript); + Console.WriteLine($" ── End Expanded Script ({label}) ──\n"); + + Console.WriteLine($"[{label}] Success!"); + } + catch (Exception ex) + { + Console.WriteLine($"[{label}] FAILED: {ex.Message}"); + Console.WriteLine($" {ex.StackTrace}"); + } + } + + // ────────────────────────────────────────────── + // Test D: Round-trip consistency test + // ────────────────────────────────────────────── + private static void RunRoundTripTest( + BlockScriptToBlueprintConverter forwardConverter, + IBlueprintToBlockScriptConverter reverseConverter, + string sourceCode, List helpers, string label) + { + try + { + // Script → Blueprint + var bp1 = forwardConverter.Convert(sourceCode, helpers); + + // Blueprint → Expanded Script + var script1 = reverseConverter.Convert(bp1); + + // Expanded Script → Blueprint (second round) + var bp2 = forwardConverter.Convert(script1, helpers); + + // Blueprint → Expanded Script (second round) + var script2 = reverseConverter.Convert(bp2); + + // Compare + Console.WriteLine($" Round 1 nodes: {bp1.Nodes.Count}, connections: {bp1.Connections.Count}"); + Console.WriteLine($" Round 2 nodes: {bp2.Nodes.Count}, connections: {bp2.Connections.Count}"); + + Console.WriteLine($"\n ── Round 1 Expanded Script ──"); + Console.WriteLine(script1); + Console.WriteLine($" ── Round 2 Expanded Script ──"); + Console.WriteLine(script2); + + // Simple structural comparison + var lines1 = script1.Split('\n', StringSplitOptions.RemoveEmptyEntries) + .Select(l => l.Trim()).Where(l => !string.IsNullOrWhiteSpace(l)).ToList(); + var lines2 = script2.Split('\n', StringSplitOptions.RemoveEmptyEntries) + .Select(l => l.Trim()).Where(l => !string.IsNullOrWhiteSpace(l)).ToList(); + + bool match = lines1.Count == lines2.Count; + if (match) + { + for (int i = 0; i < lines1.Count; i++) + { + if (lines1[i] != lines2[i]) + { + match = false; + Console.WriteLine($" Mismatch at line {i}:"); + Console.WriteLine($" Round 1: {lines1[i]}"); + Console.WriteLine($" Round 2: {lines2[i]}"); + } + } + } + else + { + Console.WriteLine($" Line count differs: Round 1={lines1.Count}, Round 2={lines2.Count}"); + } + + Console.WriteLine($"\n[{label}] {(match ? "PASS - Round-trip consistent!" : "DIFF - See differences above")}"); + } + catch (Exception ex) + { + Console.WriteLine($"[{label}] FAILED: {ex.Message}"); + Console.WriteLine($" {ex.StackTrace}"); + } + } + + // ────────────────────────────────────────────── + // Test E: Manual Blueprint construction test + // ────────────────────────────────────────────── + private static void RunManualBlueprintTest( + IBlueprintToBlockScriptConverter reverseConverter, string label) + { + try + { + var bp = new Contract.Workflow.Blueprint + { + Name = "ManualTest" + }; + + // Entry → Print("Hello") → Branch(condition, "TrueBlock", "FalseBlock") + var entry = new EntryNode(); + var printHello = new PrintNode(); + printHello.InputPins.First(p => p.Name == "Value").DefaultValue = "\"Hello\""; + var branch = new BranchNode(); + + // ConstNode for condition + var constTrue = new ConstNode { ConstName = "myCondition", ConstType = "bool", ConstValue = "true" }; + + // True branch: Print("Yes") + var printYes = new PrintNode(); + printYes.InputPins.First(p => p.Name == "Value").DefaultValue = "\"Yes\""; + + // False branch: Print("No") + var printNo = new PrintNode(); + printNo.InputPins.First(p => p.Name == "Value").DefaultValue = "\"No\""; + + bp.AddNode(entry); + bp.AddNode(printHello); + bp.AddNode(branch); + bp.AddNode(constTrue); + bp.AddNode(printYes); + bp.AddNode(printNo); + + // Exec connections + bp.AddConnection(new BlueprintConnection + { + SourceNodeId = entry.Id, + SourcePinId = entry.OutputPins.First(p => p.Name == "Exec").Id, + TargetNodeId = printHello.Id, + TargetPinId = printHello.InputPins.First(p => p.Name == "Exec").Id + }); + bp.AddConnection(new BlueprintConnection + { + SourceNodeId = printHello.Id, + SourcePinId = printHello.OutputPins.First(p => p.Name == "Exec").Id, + TargetNodeId = branch.Id, + TargetPinId = branch.InputPins.First(p => p.Name == "Exec").Id + }); + bp.AddConnection(new BlueprintConnection + { + SourceNodeId = branch.Id, + SourcePinId = branch.OutputPins.First(p => p.Name == "True").Id, + TargetNodeId = printYes.Id, + TargetPinId = printYes.InputPins.First(p => p.Name == "Exec").Id + }); + bp.AddConnection(new BlueprintConnection + { + SourceNodeId = branch.Id, + SourcePinId = branch.OutputPins.First(p => p.Name == "False").Id, + TargetNodeId = printNo.Id, + TargetPinId = printNo.InputPins.First(p => p.Name == "Exec").Id + }); + + // Data connection: ConstNode → Branch.Condition + bp.AddConnection(new BlueprintConnection + { + SourceNodeId = constTrue.Id, + SourcePinId = constTrue.OutputPins.First(p => p.Name == "Value").Id, + TargetNodeId = branch.Id, + TargetPinId = branch.InputPins.First(p => p.Name == "Condition").Id + }); + + var script = reverseConverter.Convert(bp); + + Console.WriteLine($" ── Manual Blueprint → Script ({label}) ──"); + Console.WriteLine(script); + Console.WriteLine($" ── End ({label}) ──\n"); + + Console.WriteLine($"[{label}] Success!"); + } + catch (Exception ex) + { + Console.WriteLine($"[{label}] FAILED: {ex.Message}"); + Console.WriteLine($" {ex.StackTrace}"); + } + } } diff --git a/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs b/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs index 25ba19d6..68c77b4f 100644 --- a/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs +++ b/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs @@ -219,6 +219,8 @@ public static IServiceCollection AddCoreServices(this IServiceCollection service services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); // Blueprint Services Log.Information("Registering IBlueprintService..."); diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintToBlockScriptConverter.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintToBlockScriptConverter.cs index 9c9835cc..760006bc 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintToBlockScriptConverter.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintToBlockScriptConverter.cs @@ -3,12 +3,13 @@ using System.Linq; using System.Text; using KitX.Core.Contract.Workflow; +using Serilog; namespace KitX.Core.Workflow.Blueprint; /// -/// Converts Blueprint to BlockScript using strategy pattern for node type dispatch. -/// Each node type's conversion logic lives in a dedicated INodeExportStrategy implementation. +/// Converts Blueprint back to a fully-expanded BlockScript source code. +/// Produces output equivalent to what ScriptFormatter (Phase 2 of the forward pipeline) generates. /// public class BlueprintToBlockScriptConverter : IBlueprintToBlockScriptConverter, INodeExportHelper { @@ -22,55 +23,142 @@ public BlueprintToBlockScriptConverter(IEnumerable strategi /// public Contract.Workflow.Blueprint Blueprint { get; private set; } = null!; - /// - /// Converts a blueprint to BlockScript source code string. - /// + // ────────────────────────────────────────────── + // Public API + // ────────────────────────────────────────────── + + /// public string Convert(Contract.Workflow.Blueprint blueprint) { return ConvertToBlockScript(blueprint).SourceCode; } + /// + public BlockScript ConvertToBlockScript(Contract.Workflow.Blueprint blueprint) + { + // Fast path: use stored block scopes when available + if (blueprint.BlockScopes.Count > 0) + return ConvertWithBlockScopes(blueprint); + + // Fallback: topology-based conversion + return ConvertWithTopology(blueprint); + } + /// - /// Converts a blueprint to a BlockScript object. + /// Fast-path conversion using stored block scope information. + /// Block names and membership are read directly from the Blueprint. /// - public BlockScript ConvertToBlockScript(Contract.Workflow.Blueprint blueprint) + private BlockScript ConvertWithBlockScopes(Contract.Workflow.Blueprint blueprint) { Blueprint = blueprint; - - var context = new ReverseConversionContext + var ctx = new ReverseConversionContext { Blueprint = blueprint, - Script = new BlockScript(), - PubVarIndex = 0, - BlockIndex = 0 + Script = new BlockScript() }; - // 1. Generate #PubVarBlock with all PubVars - GeneratePubVarBlock(context); + // Phase 1: Analyze blueprint — build data indexes (same as topology path) + AnalyzeBlueprint(ctx); + + // Build scopesByName lookup + foreach (var scope in blueprint.BlockScopes) + ctx.ScopesByName[scope.Name] = scope; + + // Phase 2: Generate statements from stored block membership + foreach (var scope in blueprint.BlockScopes) + { + BlockDefinition block; + if (scope.IsMainBlock) + { + block = new BlockDefinition { Type = BlockType.MainBlock, Name = "MainBlock" }; + ctx.Script.MainBlock = block; + } + else + { + block = new BlockDefinition { Type = BlockType.NamedBlock, Name = scope.Name }; + ctx.Script.NamedBlocks[scope.Name] = block; + } + + foreach (var nodeId in scope.NodeIds) + { + if (!ctx.NodeById.TryGetValue(nodeId, out var node)) continue; + var stmt = GenerateStatement(node, ctx); + if (stmt != null) + { + block.Statements.Add(stmt); + + // Track control flow nodes for block name resolution + if (stmt is FlowControlStatement flow) + { + ctx.ControlFlowMap[node.Id] = flow; + if (flow.ControlType == FlowControlType.Loop) + ctx.LoopNodes[node.Id] = node; + } + } + } + + // (LoopBodyEnd is NOT auto-inserted here — it's detected from exec connections below) + } - // 2. Generate #ConstBlock - GenerateConstBlock(context); + // Resolve control flow block names from stored ownership + foreach (var scope in blueprint.BlockScopes) + { + if (scope.OwnerNodeId == null) continue; + if (!ctx.ControlFlowMap.TryGetValue(scope.OwnerNodeId, out var flow)) continue; + + if (scope.OwnerArmName == "True" || scope.OwnerArmName == "LoopBody") + flow.TrueBlockName = scope.Name; + else if (scope.OwnerArmName == "False" || scope.OwnerArmName == "LoopEnd") + flow.FalseBlockName = scope.Name; + } - // 3. Find Entry node and process main execution flow - var entryNode = blueprint.Nodes.FirstOrDefault(n => n.NodeType == BlueprintNodeType.Entry); - if (entryNode != null) + // Regenerate flow control source code with resolved block names + foreach (var flow in ctx.ControlFlowMap.Values) { - var mainFlow = GetMainExecutionFlow(blueprint, entryNode); - var mainBlock = GenerateBlock(context, mainFlow, "MainBlock"); - context.Script.MainBlock = mainBlock; + if (flow.ControlType == FlowControlType.Branch) + flow.SourceCode = $"NextBlock = Branch({flow.ConditionExpression}, \"{flow.TrueBlockName}\", \"{flow.FalseBlockName}\");"; + else if (flow.ControlType == FlowControlType.Loop) + flow.SourceCode = $"NextBlock = Loop({flow.ConditionExpression}, \"{flow.TrueBlockName}\", \"{flow.FalseBlockName}\");"; } - // 4. Process sub-graphs (branches, loops) as NamedBlocks - ProcessSubGraphs(blueprint, context); + // Detect LoopBodyEnd: scopes whose last node's Exec output connects back to a Loop node + DetectAndInsertLoopBodyEnds(blueprint, ctx); - // 5. Generate source code - GenerateSourceCode(context.Script); + // Phase 4: Assemble source code (same as topology path) + AssembleBlockScript(ctx); - return context.Script; + return ctx.Script; + } + + /// + /// Fallback topology-based conversion for legacy blueprints without BlockScopes. + /// + private BlockScript ConvertWithTopology(Contract.Workflow.Blueprint blueprint) + { + Blueprint = blueprint; + var ctx = new ReverseConversionContext + { + Blueprint = blueprint, + Script = new BlockScript() + }; + + // Phase 1: Analyze blueprint — build indexes + AnalyzeBlueprint(ctx); + + // Phase 2: Walk execution flow — generate expanded statements + WalkExecutionFlow(ctx); + + // Phase 3: Duplicate loop conditions before LoopBodyEnd + DuplicateLoopConditions(ctx); + + // Phase 4: Assemble source code + AssembleBlockScript(ctx); + + return ctx.Script; } // ────────────────────────────────────────────── - // INodeExportHelper implementation + // INodeExportHelper (backward compat for strategies) // ────────────────────────────────────────────── /// @@ -104,329 +192,1006 @@ public string GetInputArgs(BlueprintNode node) } // ────────────────────────────────────────────── - // Core conversion logic (strategy-driven) + // Phase 1: Analyze Blueprint // ────────────────────────────────────────────── - private BlockStatement? ConvertNodeToStatement(BlueprintNode node, Contract.Workflow.Blueprint blueprint) + private void AnalyzeBlueprint(ReverseConversionContext ctx) { - if (_strategies.TryGetValue(node.NodeType, out var strategy)) - return strategy.ToStatement(node, this); + var bp = ctx.Blueprint; - return null; - } + // Build node lookup + foreach (var node in bp.Nodes) + ctx.NodeById[node.Id] = node; - private void GeneratePubVarBlock(ReverseConversionContext context) - { - var pubVarBlock = new BlockDefinition + // Classify connections and build data indexes + foreach (var conn in bp.Connections) { - Type = BlockType.PubVarBlock, - Name = "PubVarBlock" - }; + var sourceNode = bp.GetNodeById(conn.SourceNodeId); + if (sourceNode == null) continue; - var dataConnections = context.Blueprint.Connections - .Where(c => !IsExecConnection(context.Blueprint, c)) - .ToList(); + var sourcePin = sourceNode.GetPinById(conn.SourcePinId); + if (sourcePin == null) continue; - foreach (var conn in dataConnections) - { - if (string.IsNullOrEmpty(conn.PubVarName)) + if (sourcePin.Type == PinType.Execution) + { + ctx.ExecConnections.Add(conn); + } + else { - var pubVarName = $"temp_{context.PubVarIndex++}"; - conn.PubVarName = pubVarName; - pubVarBlock.Variables.Add(new VariableDeclaration + ctx.DataConnections.Add(conn); + + // Build input data map + var targetNode = bp.GetNodeById(conn.TargetNodeId); + if (targetNode != null) { - Name = pubVarName, - Type = "object" - }); - context.Blueprint.PubVarNames.Add(pubVarName); + var targetPin = targetNode.GetPinById(conn.TargetPinId); + if (targetPin != null) + { + ctx.InputDataMap[(conn.TargetNodeId, targetPin.Name)] = new DataEdgeInfo + { + SourceNode = sourceNode, + SourcePinName = sourcePin.Name, + SourcePin = sourcePin, + PubVarName = conn.PubVarName, + Connection = conn + }; + + // Track consumed outputs + ctx.ConsumedOutputs.Add((conn.SourceNodeId, sourcePin.Name)); + } + } } } - if (pubVarBlock.Variables.Count > 0) + // Assign PubVar names where needed but missing + foreach (var conn in ctx.DataConnections) { - context.Script.PubVarBlock = pubVarBlock; - } - } + if (!string.IsNullOrEmpty(conn.PubVarName)) continue; - private void GenerateConstBlock(ReverseConversionContext context) - { - var constBlock = new BlockDefinition - { - Type = BlockType.ConstBlock, - Name = "ConstBlock" - }; + var sourceNode = bp.GetNodeById(conn.SourceNodeId); + if (sourceNode == null) continue; - foreach (var node in context.Blueprint.Nodes) - { - if (node is ConstNode constNode) + // ConstNode references don't need PubVar + if (sourceNode.NodeType == BlueprintNodeType.Const) continue; + + // GetNode, CallNode, CallHelperNode outputs need PubVar if consumed downstream + if (sourceNode.NodeType is BlueprintNodeType.Get or BlueprintNodeType.Call + or BlueprintNodeType.CallHelper) { - constBlock.Variables.Add(new VariableDeclaration + var sourcePin = sourceNode.GetPinById(conn.SourcePinId); + if (sourcePin == null) continue; + + var pubVar = GeneratePubVarName(ctx); + conn.PubVarName = pubVar; + ctx.AutoPubVars.Add(pubVar); + + // Update the InputDataMap entry + var targetNode = bp.GetNodeById(conn.TargetNodeId); + if (targetNode != null) { - Name = constNode.ConstName, - Type = constNode.ConstType, - DefaultValue = constNode.ConstValue - }); + var targetPin = targetNode.GetPinById(conn.TargetPinId); + if (targetPin != null && ctx.InputDataMap.TryGetValue( + (conn.TargetNodeId, targetPin.Name), out var info)) + { + info.PubVarName = pubVar; + } + } + + Log.Debug("[BlueprintToScript] Auto-assigned PubVar {PubVar} for {NodeType}.{Pin}", + pubVar, sourceNode.NodeType, sourcePin.Name); } } - if (constBlock.Variables.Count > 0) + // Collect all PubVar names + foreach (var conn in ctx.DataConnections) { - context.Script.ConstBlock = constBlock; + if (!string.IsNullOrEmpty(conn.PubVarName) && !ctx.AllPubVars.Contains(conn.PubVarName)) + ctx.AllPubVars.Add(conn.PubVarName); } + + Log.Debug("[BlueprintToScript] Phase 1 done: {Nodes} nodes, {Exec} exec conns, {Data} data conns, {PubVars} pubvars", + ctx.NodeById.Count, ctx.ExecConnections.Count, ctx.DataConnections.Count, ctx.AllPubVars.Count); } - private List GetMainExecutionFlow(Contract.Workflow.Blueprint blueprint, BlueprintNode entryNode) + // ────────────────────────────────────────────── + // Phase 2: Walk Execution Flow + // ────────────────────────────────────────────── + + private void WalkExecutionFlow(ReverseConversionContext ctx) { - var flow = new List(); + var bp = ctx.Blueprint; + var entryNode = bp.Nodes.FirstOrDefault(n => n.NodeType == BlueprintNodeType.Entry); + if (entryNode == null) + { + Log.Warning("[BlueprintToScript] No Entry node found"); + return; + } + + // Main block + var mainBlock = new BlockDefinition + { + Type = BlockType.MainBlock, + Name = "MainBlock" + }; + ctx.Script.MainBlock = mainBlock; + + // Walk from entry var visited = new HashSet(); - CollectMainFlow(blueprint, entryNode, flow, visited, out _); - return flow; + WalkNode(entryNode, mainBlock, ctx, visited, loopbackTargetId: null); + + // Process control flow sub-graphs + ProcessSubGraphs(ctx); } - private void CollectMainFlow(Contract.Workflow.Blueprint blueprint, BlueprintNode node, List flow, - HashSet visited, out bool hitControlFlow) + private void WalkNode(BlueprintNode node, BlockDefinition currentBlock, + ReverseConversionContext ctx, HashSet visited, string? loopbackTargetId) { - if (visited.Contains(node.Id)) + if (visited.Contains(node.Id)) return; + visited.Add(node.Id); + + // If this node is the loopback target (we've looped back to the Loop node), insert LoopBodyEnd + if (loopbackTargetId != null && node.Id == loopbackTargetId) { - hitControlFlow = false; + currentBlock.Statements.Add(new FlowControlStatement + { + ControlType = FlowControlType.LoopBodyEnd, + SourceCode = "LoopBodyEnd();", + LineNumber = 1 + }); return; } - visited.Add(node.Id); - flow.Add(node); + // Generate statement for this node + var stmt = GenerateStatement(node, ctx); + if (stmt != null) + { + currentBlock.Statements.Add(stmt); + + // Track flow control for sub-graph processing + if (stmt is FlowControlStatement flow) + { + ctx.ControlFlowMap[node.Id] = flow; + + // Record loop condition info for Phase 3 + if (flow.ControlType == FlowControlType.Loop) + { + ctx.LoopNodes[node.Id] = node; + } + } + } - // Use strategy to check if this is a control flow node - if (_strategies.TryGetValue(node.NodeType, out var strategy) && strategy.IsControlFlow) + // Check if this is a control flow node — stop main flow walk + if (node.NodeType is BlueprintNodeType.Branch or BlueprintNodeType.Loop) { - hitControlFlow = true; + ctx.PendingControlFlowNodes.Add(node); return; } + // Follow exec output var execOut = node.OutputPins.FirstOrDefault(p => p.Name == "Exec"); if (execOut == null) { - hitControlFlow = false; + // Dead end inside a loop body → insert LoopBodyEnd + if (loopbackTargetId != null) + { + currentBlock.Statements.Add(new FlowControlStatement + { + ControlType = FlowControlType.LoopBodyEnd, + SourceCode = "LoopBodyEnd();", + LineNumber = 1 + }); + } return; } - var execConn = blueprint.Connections - .FirstOrDefault(c => c.SourcePinId == execOut.Id); - + var execConn = ctx.ExecConnections.FirstOrDefault(c => c.SourcePinId == execOut.Id); if (execConn == null) { - hitControlFlow = false; + // Dead end inside a loop body → insert LoopBodyEnd + if (loopbackTargetId != null) + { + currentBlock.Statements.Add(new FlowControlStatement + { + ControlType = FlowControlType.LoopBodyEnd, + SourceCode = "LoopBodyEnd();", + LineNumber = 1 + }); + } return; } - var nextNode = blueprint.GetNodeById(execConn.TargetNodeId); - if (nextNode == null) + var nextNode = ctx.Blueprint.GetNodeById(execConn.TargetNodeId); + if (nextNode == null) return; + + // Check for loopback (next node is the loop we're looping back to) + if (loopbackTargetId != null && nextNode.Id == loopbackTargetId) { - hitControlFlow = false; + currentBlock.Statements.Add(new FlowControlStatement + { + ControlType = FlowControlType.LoopBodyEnd, + SourceCode = "LoopBodyEnd();", + LineNumber = 1 + }); return; } - CollectMainFlow(blueprint, nextNode, flow, visited, out hitControlFlow); + WalkNode(nextNode, currentBlock, ctx, visited, loopbackTargetId); } - private void ProcessSubGraphs(Contract.Workflow.Blueprint blueprint, ReverseConversionContext context) + /// + /// Generates a BlockStatement for the given node in expanded format. + /// + private BlockStatement? GenerateStatement(BlueprintNode node, ReverseConversionContext ctx) { - var processedTargets = new HashSet(); + switch (node.NodeType) + { + case BlueprintNodeType.Entry: + return null; // Entry nodes produce no statement + + case BlueprintNodeType.Print: + return GeneratePrintStatement(node, ctx); + + case BlueprintNodeType.Pause: + return GeneratePauseStatement(node, ctx); + + case BlueprintNodeType.Set: + return GenerateSetStatement(node, ctx); + + case BlueprintNodeType.Get: + return GenerateGetStatement(node, ctx); + + case BlueprintNodeType.Call: + return GenerateCallStatement(node, ctx); + + case BlueprintNodeType.CallHelper: + return GenerateCallHelperStatement(node, ctx); + + case BlueprintNodeType.Branch: + return GenerateBranchStatement(node, ctx); + + case BlueprintNodeType.Loop: + return GenerateLoopStatement(node, ctx); - foreach (var node in blueprint.Nodes) + case BlueprintNodeType.Break: + return new FlowControlStatement + { + ControlType = FlowControlType.Break, + SourceCode = "Break();", + LineNumber = 1 + }; + + default: + Log.Warning("[BlueprintToScript] Unhandled node type: {NodeType}", node.NodeType); + return null; + } + } + + private BlockStatement? GeneratePrintStatement(BlueprintNode node, ReverseConversionContext ctx) + { + var value = ResolveInputValue(node, "Value", ctx); + var sourceCode = $"Print({value});"; + return new ExpressionStatement { - if (!_strategies.TryGetValue(node.NodeType, out var strategy) || !strategy.IsControlFlow) - continue; + Expression = $"Print({value})", + SourceCode = sourceCode, + LineNumber = 1 + }; + } - foreach (var arm in strategy.GetOutputArms(node)) - { - ProcessOutputArm(blueprint, node, arm.PinName, arm.IsLoopback, - context, processedTargets); - } + private BlockStatement? GeneratePauseStatement(BlueprintNode node, ReverseConversionContext ctx) + { + var ms = ResolveInputValue(node, "Milliseconds", ctx); + var sourceCode = $"Pause({ms});"; + return new ExpressionStatement + { + Expression = $"Pause({ms})", + SourceCode = sourceCode, + LineNumber = 1 + }; + } + + private BlockStatement? GenerateSetStatement(BlueprintNode node, ReverseConversionContext ctx) + { + if (node is not SetNode setNode) return null; + var value = ResolveInputValue(node, "Value", ctx); + var sourceCode = $"Set(\"{setNode.VarName}\", {value});"; + return new ExpressionStatement + { + Expression = $"Set(\"{setNode.VarName}\", {value})", + SourceCode = sourceCode, + LineNumber = 1 + }; + } + + private BlockStatement? GenerateGetStatement(BlueprintNode node, ReverseConversionContext ctx) + { + if (node is not GetNode getNode) return null; + + // Only generate statement if Value output is consumed + var valuePin = node.OutputPins.FirstOrDefault(p => p.Name == "Value"); + if (valuePin == null || !ctx.ConsumedOutputs.Contains((node.Id, "Value"))) + { + // Not consumed — skip (invisible in exec flow) + return null; } + + // Find the PubVar assigned to this Get's output + var pubVar = FindOutputPubVar(node, "Value", ctx); + if (pubVar == null) return null; + + var sourceCode = $"{pubVar} = Get(\"{getNode.VarName}\");"; + return new ExpressionStatement + { + Expression = $"Get(\"{getNode.VarName}\")", + SourceCode = sourceCode, + LineNumber = 1 + }; } - private void ProcessOutputArm( - Contract.Workflow.Blueprint blueprint, - BlueprintNode controlNode, - string outputPinName, - bool isLoopback, - ReverseConversionContext context, - HashSet processedTargets) + private BlockStatement? GenerateCallStatement(BlueprintNode node, ReverseConversionContext ctx) { - var pin = controlNode.OutputPins.FirstOrDefault(p => p.Name == outputPinName); - if (pin == null) return; + if (node is not CallNode callNode) return null; - var conn = blueprint.Connections.FirstOrDefault(c => c.SourcePinId == pin.Id); - if (conn == null || processedTargets.Contains(conn.TargetNodeId)) return; + // Resolve arguments (all non-Exec input pins) + var args = ResolveAllArgs(node, ctx); + var funcRef = string.IsNullOrEmpty(callNode.PluginName) + ? callNode.FunctionName + : $"{callNode.PluginName}.{callNode.FunctionName}"; - var targetNode = blueprint.GetNodeById(conn.TargetNodeId); - if (targetNode == null) return; + // Check if Return output is consumed → needs PubVar assignment + var returnPin = node.OutputPins.FirstOrDefault(p => p.Name == "Return"); + bool hasReturn = returnPin != null && ctx.ConsumedOutputs.Contains((node.Id, "Return")); - var blockName = $"Block_{context.BlockIndex++}"; - var loopbackTargetId = isLoopback ? controlNode.Id : null; - var block = CollectSubGraph(blueprint, targetNode, context, processedTargets, loopbackTargetId); - context.Script.NamedBlocks[blockName] = block; - processedTargets.Add(conn.TargetNodeId); + if (hasReturn) + { + var pubVar = FindOutputPubVar(node, "Return", ctx); + var sourceCode = $"{pubVar} = {funcRef}({args});"; + return new ExpressionStatement + { + Expression = $"{funcRef}({args})", + SourceCode = sourceCode, + LineNumber = 1 + }; + } + else + { + var sourceCode = $"{funcRef}({args});"; + return new ExpressionStatement + { + Expression = $"{funcRef}({args})", + SourceCode = sourceCode, + LineNumber = 1 + }; + } + } - if (context.ControlFlowMap.TryGetValue(controlNode.Id, out var flow)) + private BlockStatement? GenerateCallHelperStatement(BlueprintNode node, ReverseConversionContext ctx) + { + if (node is not CallHelperNode helperNode) return null; + + // Resolve arguments + var args = ResolveAllArgs(node, ctx); + var funcName = helperNode.HelperFunctionName; + + // Check if Return output is consumed → needs PubVar assignment + var returnPin = node.OutputPins.FirstOrDefault(p => p.Name == "Return"); + bool hasReturn = returnPin != null && ctx.ConsumedOutputs.Contains((node.Id, "Return")); + + if (hasReturn) { - // Determine which block name property to set based on arm index - var arms = _strategies[controlNode.NodeType].GetOutputArms(controlNode).ToList(); - var armIndex = arms.FindIndex(a => a.PinName == outputPinName); - if (armIndex == 0) - flow.TrueBlockName = blockName; - else - flow.FalseBlockName = blockName; + var pubVar = FindOutputPubVar(node, "Return", ctx); + var sourceCode = $"{pubVar} = {funcName}({args});"; + return new ExpressionStatement + { + Expression = $"{funcName}({args})", + SourceCode = sourceCode, + LineNumber = 1 + }; + } + else + { + var sourceCode = $"{funcName}({args});"; + return new ExpressionStatement + { + Expression = $"{funcName}({args})", + SourceCode = sourceCode, + LineNumber = 1 + }; } } - private BlockDefinition CollectSubGraph(Contract.Workflow.Blueprint blueprint, BlueprintNode startNode, - ReverseConversionContext context, HashSet processedTargets, string? loopbackTargetId = null) + private BlockStatement GenerateBranchStatement(BlueprintNode node, ReverseConversionContext ctx) { - var block = new BlockDefinition + var condition = ResolveInputValue(node, "Condition", ctx); + + // Block names will be filled during sub-graph processing + return new FlowControlStatement { - Type = BlockType.NamedBlock, - Name = startNode.NodeType.ToString() + ControlType = FlowControlType.Branch, + ConditionExpression = condition, + TrueBlockName = string.Empty, // filled later + FalseBlockName = string.Empty, // filled later + SourceCode = $"NextBlock = Branch({condition}, \"\", \"\");", + LineNumber = 1 }; + } - var visited = new HashSet(); - CollectNodesRecursive(blueprint, startNode, block, context, visited, processedTargets, loopbackTargetId); + private BlockStatement GenerateLoopStatement(BlueprintNode node, ReverseConversionContext ctx) + { + var condition = ResolveInputValue(node, "Condition", ctx); - return block; + return new FlowControlStatement + { + ControlType = FlowControlType.Loop, + ConditionExpression = condition, + TrueBlockName = string.Empty, // filled later + FalseBlockName = string.Empty, // filled later + SourceCode = $"NextBlock = Loop({condition}, \"\", \"\");", + LineNumber = 1 + }; } - private void CollectNodesRecursive(Contract.Workflow.Blueprint blueprint, BlueprintNode node, - BlockDefinition block, ReverseConversionContext context, HashSet visited, HashSet processedTargets, - string? loopbackTargetId) + // ────────────────────────────────────────────── + // Input Resolution + // ────────────────────────────────────────────── + + /// + /// Resolves the value for an input pin by tracing data connections. + /// + private string ResolveInputValue(BlueprintNode node, string pinName, ReverseConversionContext ctx) { - if (visited.Contains(node.Id)) return; - visited.Add(node.Id); + var pin = node.InputPins.FirstOrDefault(p => p.Name == pinName); + if (pin == null) return string.Empty; - var nodeStatement = ConvertNodeToStatement(node, blueprint); - if (nodeStatement != null) + // Check if there's a data connection to this pin + if (ctx.InputDataMap.TryGetValue((node.Id, pin.Name), out var info)) { - block.Statements.Add(nodeStatement); + // Source is ConstNode → use ConstName + if (info.SourceNode is ConstNode constNode) + return constNode.ConstName; + + // Has PubVarName → use it + if (!string.IsNullOrEmpty(info.PubVarName)) + return info.PubVarName; + } + + // No connection → use default value + var defaultValue = pin.DefaultValue ?? string.Empty; + return FormatLiteralValue(defaultValue, ctx); + } + + /// + /// Formats a literal value for output, adding quotes to strings when needed. + /// + private string FormatLiteralValue(string value, ReverseConversionContext? ctx = null) + { + if (string.IsNullOrEmpty(value)) return value; + + // Already quoted + if (value.StartsWith("\"")) return value; + + // PubVar reference + if (value.StartsWith("vaaa") && value.Length >= 8) return value; - if (nodeStatement is FlowControlStatement flow) + // Numeric literal + if (int.TryParse(value, out _) || double.TryParse(value, out _)) return value; + + // Boolean literal + if (value == "true" || value == "false") return value; + + // Get/Set/Helper expressions + if (value.Contains("(")) return value; + + // Known ConstName reference + if (ctx != null && ctx.Blueprint.Nodes.OfType().Any(c => c.ConstName == value)) + return value; + + // Default: treat as string, add quotes + return $"\"{value}\""; + } + + /// + /// Resolves all non-Exec input arguments, returning comma-separated string. + /// + private string ResolveAllArgs(BlueprintNode node, ReverseConversionContext ctx) + { + var args = new List(); + foreach (var pin in node.InputPins) + { + if (pin.Name != "Exec") { - context.ControlFlowMap[node.Id] = flow; + args.Add(ResolveInputValue(node, pin.Name, ctx)); } } + return string.Join(", ", args); + } + + /// + /// Finds the PubVar name assigned to a node's output pin. + /// Looks through all data connections from this pin. + /// + private string? FindOutputPubVar(BlueprintNode node, string pinName, ReverseConversionContext ctx) + { + var pin = node.OutputPins.FirstOrDefault(p => p.Name == pinName); + if (pin == null) return null; + + var conn = ctx.DataConnections.FirstOrDefault(c => c.SourcePinId == pin.Id); + return conn?.PubVarName; + } - foreach (var outPin in node.OutputPins) + // ────────────────────────────────────────────── + // Sub-graph Processing + // ────────────────────────────────────────────── + + private void ProcessSubGraphs(ReverseConversionContext ctx) + { + var processedTargets = new HashSet(); + var processedNodes = new HashSet(); + + // Keep processing until no new control flow nodes are discovered + while (ctx.PendingControlFlowNodes.Count > 0) { - if (outPin.Name == "Exec") + // Snapshot current pending nodes + var pendingNodes = ctx.PendingControlFlowNodes.ToList(); + ctx.PendingControlFlowNodes.Clear(); + + foreach (var node in pendingNodes) { - var conn = blueprint.Connections.FirstOrDefault(c => c.SourcePinId == outPin.Id); - if (conn != null) + if (processedNodes.Contains(node.Id)) continue; + processedNodes.Add(node.Id); + + switch (node.NodeType) { - var nextNode = blueprint.GetNodeById(conn.TargetNodeId); - if (nextNode != null) - { - if (nextNode.Id == loopbackTargetId) - { - block.Statements.Add(new FlowControlStatement - { - ControlType = FlowControlType.LoopBodyEnd, - SourceCode = "LoopBodyEnd();", - LineNumber = 1 - }); - return; - } - - CollectNodesRecursive(blueprint, nextNode, block, context, visited, processedTargets, loopbackTargetId); - } + case BlueprintNodeType.Branch: + ProcessBranchSubGraphs(node, ctx, processedTargets); + break; + case BlueprintNodeType.Loop: + ProcessLoopSubGraphs(node, ctx, processedTargets); + break; } } } + + // Update flow control statements with actual block names + foreach (var kvp in ctx.ControlFlowMap) + { + var flow = kvp.Value; + if (ctx.BlockNameAssignments.TryGetValue(kvp.Key, out var assignments)) + { + flow.TrueBlockName = assignments.TrueBlockName; + flow.FalseBlockName = assignments.FalseBlockName; + + // Regenerate source code + if (flow.ControlType == FlowControlType.Branch) + flow.SourceCode = $"NextBlock = Branch({flow.ConditionExpression}, \"{flow.TrueBlockName}\", \"{flow.FalseBlockName}\");"; + else if (flow.ControlType == FlowControlType.Loop) + flow.SourceCode = $"NextBlock = Loop({flow.ConditionExpression}, \"{flow.TrueBlockName}\", \"{flow.FalseBlockName}\");"; + } + } } - private BlockDefinition GenerateBlock(ReverseConversionContext context, - List nodes, string blockName) + private void ProcessBranchSubGraphs(BlueprintNode branchNode, ReverseConversionContext ctx, + HashSet processedTargets) { - var block = new BlockDefinition + var arms = new[] { ("True", false), ("False", false) }; + + var trueBlockName = string.Empty; + var falseBlockName = string.Empty; + + // Determine if this branch is inside a loop body (inherit loopback target) + var currentLoopback = ctx.CurrentLoopbackTargetId; + + foreach (var (pinName, _) in arms) { - Type = blockName == "MainBlock" ? BlockType.MainBlock : BlockType.NamedBlock, - Name = blockName - }; + var pin = branchNode.OutputPins.FirstOrDefault(p => p.Name == pinName); + if (pin == null) continue; + + var conn = ctx.ExecConnections.FirstOrDefault(c => c.SourcePinId == pin.Id); + if (conn == null || processedTargets.Contains(conn.TargetNodeId)) continue; + + var targetNode = ctx.Blueprint.GetNodeById(conn.TargetNodeId); + if (targetNode == null) continue; + + var blockName = GenerateBlockName(ctx); + var block = new BlockDefinition + { + Type = BlockType.NamedBlock, + Name = blockName + }; - foreach (var node in nodes) + var visited = new HashSet(); + WalkNode(targetNode, block, ctx, visited, loopbackTargetId: currentLoopback); + + ctx.Script.NamedBlocks[blockName] = block; + processedTargets.Add(conn.TargetNodeId); + + if (pinName == "True") trueBlockName = blockName; + else falseBlockName = blockName; + } + + ctx.BlockNameAssignments[branchNode.Id] = (trueBlockName, falseBlockName); + } + + private void ProcessLoopSubGraphs(BlueprintNode loopNode, ReverseConversionContext ctx, + HashSet processedTargets) + { + var loopBodyBlockName = string.Empty; + var loopEndBlockName = string.Empty; + + // LoopBody arm (loops back to this LoopNode) + var loopBodyPin = loopNode.OutputPins.FirstOrDefault(p => p.Name == "LoopBody"); + if (loopBodyPin != null) { - var statement = ConvertNodeToStatement(node, context.Blueprint); - if (statement != null) + var conn = ctx.ExecConnections.FirstOrDefault(c => c.SourcePinId == loopBodyPin.Id); + if (conn != null && !processedTargets.Contains(conn.TargetNodeId)) { - block.Statements.Add(statement); + var targetNode = ctx.Blueprint.GetNodeById(conn.TargetNodeId); + if (targetNode != null) + { + loopBodyBlockName = GenerateBlockName(ctx); + var block = new BlockDefinition + { + Type = BlockType.NamedBlock, + Name = loopBodyBlockName + }; - if (statement is FlowControlStatement flow) + // Set loopback context for nested sub-blocks + ctx.CurrentLoopbackTargetId = loopNode.Id; + + var visited = new HashSet(); + WalkNode(targetNode, block, ctx, visited, loopbackTargetId: loopNode.Id); + + ctx.Script.NamedBlocks[loopBodyBlockName] = block; + processedTargets.Add(conn.TargetNodeId); + + // Record loop body block for Phase 3 condition duplication + ctx.LoopBodyBlocks[loopNode.Id] = block; + } + } + } + + // LoopEnd arm + var loopEndPin = loopNode.OutputPins.FirstOrDefault(p => p.Name == "LoopEnd"); + if (loopEndPin != null) + { + var conn = ctx.ExecConnections.FirstOrDefault(c => c.SourcePinId == loopEndPin.Id); + if (conn != null && !processedTargets.Contains(conn.TargetNodeId)) + { + var targetNode = ctx.Blueprint.GetNodeById(conn.TargetNodeId); + if (targetNode != null) { - context.ControlFlowMap[node.Id] = flow; + loopEndBlockName = GenerateBlockName(ctx); + var block = new BlockDefinition + { + Type = BlockType.NamedBlock, + Name = loopEndBlockName + }; + + var visited = new HashSet(); + WalkNode(targetNode, block, ctx, visited, loopbackTargetId: null); + + ctx.Script.NamedBlocks[loopEndBlockName] = block; + processedTargets.Add(conn.TargetNodeId); } } } - return block; + ctx.BlockNameAssignments[loopNode.Id] = (loopBodyBlockName, loopEndBlockName); } - private bool IsExecConnection(Contract.Workflow.Blueprint blueprint, BlueprintConnection conn) + // ────────────────────────────────────────────── + // Phase 3: Loop Condition Duplication + // ────────────────────────────────────────────── + + private void DuplicateLoopConditions(ReverseConversionContext ctx) { - var sourceNode = blueprint.GetNodeById(conn.SourceNodeId); - var sourcePin = sourceNode?.OutputPins.FirstOrDefault(p => p.Id == conn.SourcePinId); - return sourcePin?.Name == "Exec"; + foreach (var kvp in ctx.LoopBodyBlocks) + { + var loopNodeId = kvp.Key; + var loopBodyBlock = kvp.Value; + + if (!ctx.LoopNodes.TryGetValue(loopNodeId, out var loopNode)) continue; + + // Get the condition expression for this loop + var flow = ctx.ControlFlowMap.TryGetValue(loopNodeId, out var f) ? f : null; + if (flow == null) continue; + + var conditionExpr = flow.ConditionExpression; + if (string.IsNullOrEmpty(conditionExpr)) continue; + + // Find the statements in the main block that evaluate the condition + // These are the statements between the last non-related statement and the Loop statement + // For simplicity: find the condition evaluation chain (Get + CallHelper statements before the Loop) + var condStmts = FindConditionStatements(loopNode, ctx); + if (condStmts.Count == 0) continue; + + // Insert duplicates before each LoopBodyEnd in the loop body block + var insertions = new List<(int index, List stmts)>(); + + for (int i = 0; i < loopBodyBlock.Statements.Count; i++) + { + var stmt = loopBodyBlock.Statements[i]; + if (stmt is FlowControlStatement flowStmt + && flowStmt.ControlType == FlowControlType.LoopBodyEnd) + { + var dupStmts = condStmts.Select(CloneStatement).ToList(); + insertions.Add((i, dupStmts.Cast().ToList())); + } + } + + // Apply insertions in reverse order to preserve indices + foreach (var (index, stmts) in insertions.OrderByDescending(x => x.index)) + loopBodyBlock.Statements.InsertRange(index, stmts); + } } - private void GenerateSourceCode(BlockScript script) + /// + /// Finds the condition evaluation statements for a Loop node. + /// These are the Get/CallHelper statements that compute the condition value. + /// + private List FindConditionStatements(BlueprintNode loopNode, + ReverseConversionContext ctx) + { + var result = new List(); + + // The loop's condition comes from a data input pin. + // Trace back through the data chain to find all evaluation statements. + var conditionPin = loopNode.InputPins.FirstOrDefault(p => p.Name == "Condition"); + if (conditionPin == null) return result; + + // Find which node feeds the condition + if (!ctx.InputDataMap.TryGetValue((loopNode.Id, "Condition"), out var condInfo)) + return result; + + // The condition is evaluated by some chain of nodes. + // We need to find all statements in the main block that correspond to this chain. + // Strategy: find statements whose PubVar names appear in the condition expression chain. + + var chainPubVars = new HashSet(); + CollectConditionChain(condInfo, ctx, chainPubVars); + + if (chainPubVars.Count == 0) return result; + + // Find matching statements in the main block + if (ctx.Script.MainBlock == null) return result; + + var loopFlowStmt = ctx.ControlFlowMap.TryGetValue(loopNode.Id, out var lf) ? lf : null; + if (loopFlowStmt == null) return result; + + // Collect statements before the Loop that match the condition chain + var loopIndex = ctx.Script.MainBlock.Statements.IndexOf(loopFlowStmt); + if (loopIndex < 0) return result; + + for (int i = loopIndex - 1; i >= 0; i--) + { + var stmt = ctx.Script.MainBlock.Statements[i]; + if (stmt is ExpressionStatement exprStmt) + { + // Check if this statement assigns to one of the chain PubVars + foreach (var pv in chainPubVars) + { + if (exprStmt.SourceCode.StartsWith($"{pv} = ")) + { + result.Insert(0, exprStmt); + break; + } + } + } + } + + return result; + } + + /// + /// Recursively collects all PubVar names in the data chain leading to a condition. + /// + private void CollectConditionChain(DataEdgeInfo info, ReverseConversionContext ctx, + HashSet pubVars) + { + if (!string.IsNullOrEmpty(info.PubVarName)) + pubVars.Add(info.PubVarName); + + // If the source is a CallHelper/Call, trace its argument inputs + if (info.SourceNode.NodeType is BlueprintNodeType.CallHelper or BlueprintNodeType.Call) + { + foreach (var pin in info.SourceNode.InputPins) + { + if (pin.Name == "Exec") continue; + if (ctx.InputDataMap.TryGetValue((info.SourceNode.Id, pin.Name), out var argInfo)) + { + CollectConditionChain(argInfo, ctx, pubVars); + } + } + } + } + + private static ExpressionStatement CloneStatement(ExpressionStatement source) => new() + { + Expression = source.Expression, + SourceCode = source.SourceCode, + LineNumber = source.LineNumber + }; + + // ────────────────────────────────────────────── + // Phase 4: Assemble Source Code + // ────────────────────────────────────────────── + + private void AssembleBlockScript(ReverseConversionContext ctx) { var sb = new StringBuilder(); - if (script.ConstBlock != null && script.ConstBlock.Variables.Count > 0) + // #ConstBlock + var constNodes = ctx.Blueprint.Nodes.OfType().ToList(); + if (constNodes.Count > 0) { sb.AppendLine("#ConstBlock"); - foreach (var variable in script.ConstBlock.Variables) + foreach (var cn in constNodes) { - sb.AppendLine($"const {variable.Type} {variable.Name} = {variable.DefaultValue};"); + if (string.IsNullOrEmpty(cn.ConstValue)) + { + // Variable without initial value + sb.AppendLine($"{cn.ConstType} {cn.ConstName};"); + } + else + { + var value = cn.ConstType == "string" ? $"\"{cn.ConstValue}\"" : cn.ConstValue; + sb.AppendLine($"const {cn.ConstType} {cn.ConstName} = {value};"); + } } sb.AppendLine(); } - if (script.PubVarBlock != null && script.PubVarBlock.Variables.Count > 0) + // #PubVarBlock + if (ctx.AllPubVars.Count > 0) { sb.AppendLine("#PubVarBlock"); - foreach (var variable in script.PubVarBlock.Variables) + foreach (var pv in ctx.AllPubVars) { - sb.AppendLine($"{variable.Type} {variable.Name};"); + sb.AppendLine($"object {pv};"); } sb.AppendLine(); } - if (script.MainBlock != null) + // #MainBlock + if (ctx.Script.MainBlock != null) { sb.AppendLine("#MainBlock"); - foreach (var statement in script.MainBlock.Statements) + foreach (var stmt in ctx.Script.MainBlock.Statements) { - sb.AppendLine(statement.SourceCode); + sb.AppendLine(stmt.SourceCode); } sb.AppendLine(); } - foreach (var kvp in script.NamedBlocks) + // Named blocks + foreach (var kvp in ctx.Script.NamedBlocks) { sb.AppendLine($"#Block {kvp.Key}"); - foreach (var statement in kvp.Value.Statements) + foreach (var stmt in kvp.Value.Statements) { - sb.AppendLine(statement.SourceCode); + sb.AppendLine(stmt.SourceCode); } sb.AppendLine(); } - script.SourceCode = sb.ToString(); + ctx.Script.SourceCode = sb.ToString().TrimEnd(); + + Log.Debug("[BlueprintToScript] Phase 4 done. Source code length: {Len}", ctx.Script.SourceCode.Length); } + // ────────────────────────────────────────────── + // Helpers + // ────────────────────────────────────────────── + + /// + /// Detects which block scopes end with a LoopBodyEnd by checking if their + /// last node's Exec output connects back to a Loop node. + /// + private void DetectAndInsertLoopBodyEnds(Contract.Workflow.Blueprint bp, ReverseConversionContext ctx) + { + foreach (var scope in bp.BlockScopes) + { + if (scope.NodeIds.Count == 0) continue; + + var lastNodeId = scope.NodeIds[scope.NodeIds.Count - 1]; + if (!ctx.NodeById.TryGetValue(lastNodeId, out var lastNode)) continue; + + // Check if last node's Exec output connects to a Loop node + var execOutPin = lastNode.OutputPins.FirstOrDefault(p => p.Name == "Exec"); + if (execOutPin == null) continue; + + var execConn = ctx.ExecConnections.FirstOrDefault(c => c.SourcePinId == execOutPin.Id); + if (execConn == null) continue; + + if (!ctx.NodeById.TryGetValue(execConn.TargetNodeId, out var targetNode)) continue; + if (targetNode.NodeType != BlueprintNodeType.Loop) continue; + + // Find the block containing the Loop node + var returnToBlock = FindScopeContainingNode(targetNode.Id, bp); + if (returnToBlock == null) continue; + + // Find the block definition for this scope + BlockDefinition? blockDef = scope.IsMainBlock + ? ctx.Script.MainBlock + : ctx.Script.NamedBlocks.GetValueOrDefault(scope.Name); + if (blockDef == null) continue; + + blockDef.Statements.Add(new FlowControlStatement + { + ControlType = FlowControlType.LoopBodyEnd, + SourceCode = $"NextBlock = LoopBodyEnd(\"{returnToBlock}\");", + LineNumber = 1, + LoopBodyEndReturnTo = returnToBlock + }); + } + } + + /// + /// Finds the block scope name containing a given node ID. + /// + private static string? FindScopeContainingNode(string nodeId, Contract.Workflow.Blueprint bp) + { + foreach (var scope in bp.BlockScopes) + { + if (scope.NodeIds.Contains(nodeId)) + return scope.Name; + } + return null; + } + + private string GeneratePubVarName(ReverseConversionContext ctx) + { + return $"vaaa{ctx.PubVarCounter++:D4}"; + } + + private string GenerateBlockName(ReverseConversionContext ctx) + { + return $"Block_{ctx.BlockCounter++}"; + } + + // ────────────────────────────────────────────── + // Context classes + // ────────────────────────────────────────────── + private class ReverseConversionContext { public required Contract.Workflow.Blueprint Blueprint { get; set; } public required BlockScript Script { get; set; } - public int PubVarIndex { get; set; } - public int BlockIndex { get; set; } + + // Phase 1: Analysis + public Dictionary NodeById { get; set; } = new(); + public List ExecConnections { get; set; } = new(); + public List DataConnections { get; set; } = new(); + public Dictionary<(string nodeId, string pinName), DataEdgeInfo> InputDataMap { get; set; } = new(); + public HashSet<(string nodeId, string pinName)> ConsumedOutputs { get; set; } = new(); + public List AllPubVars { get; set; } = new(); + public List AutoPubVars { get; set; } = new(); + public int PubVarCounter { get; set; } = 1; + public int BlockCounter { get; set; } = 0; + + // Phase 2: Execution walk public Dictionary ControlFlowMap { get; set; } = new(); + public List PendingControlFlowNodes { get; set; } = new(); + public Dictionary LoopNodes { get; set; } = new(); + public Dictionary LoopBodyBlocks { get; set; } = new(); + public Dictionary BlockNameAssignments { get; set; } = new(); + + // Block scope fast path + public Dictionary ScopesByName { get; set; } = new(); + + /// + /// Current loopback target ID when walking inside a loop body. + /// Set by ProcessLoopSubGraphs, read by ProcessBranchSubGraphs. + /// + public string? CurrentLoopbackTargetId { get; set; } + } + + private class DataEdgeInfo + { + public BlueprintNode SourceNode { get; set; } = null!; + public string SourcePinName { get; set; } = string.Empty; + public BlueprintPin SourcePin { get; set; } = null!; + public string? PubVarName { get; set; } + public BlueprintConnection Connection { get; set; } = null!; } } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/CallNodeExportStrategy.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/CallNodeExportStrategy.cs index a6be4c5b..57aba8b4 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/CallNodeExportStrategy.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/CallNodeExportStrategy.cs @@ -11,10 +11,14 @@ public class CallNodeExportStrategy : INodeExportStrategy { if (node is not CallNode call) return null; var callArgs = helper.GetInputArgs(call); + var funcRef = string.IsNullOrEmpty(call.PluginName) + ? call.FunctionName + : $"{call.PluginName}.{call.FunctionName}"; + var sourceCode = $"{funcRef}({callArgs})"; return new ExpressionStatement { - Expression = $"{call.FunctionName}({callArgs});", - SourceCode = $"{call.FunctionName}({callArgs});", + Expression = sourceCode, + SourceCode = sourceCode + ";", LineNumber = 1 }; } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/GetNodeExportStrategy.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/GetNodeExportStrategy.cs new file mode 100644 index 00000000..1f67d3b2 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/GetNodeExportStrategy.cs @@ -0,0 +1,24 @@ +using KitX.Core.Contract.Workflow; + +namespace KitX.Core.Workflow.Blueprint.ExportStrategies; + +public class GetNodeExportStrategy : INodeExportStrategy +{ + public BlueprintNodeType NodeType => BlueprintNodeType.Get; + public bool IsControlFlow => false; + + public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) + { + if (node is not GetNode getNode) return null; + var varName = getNode.VarName; + var sourceCode = $"Get(\"{varName}\")"; + return new ExpressionStatement + { + Expression = sourceCode, + SourceCode = sourceCode + ";", + LineNumber = 1 + }; + } + + public IEnumerable GetOutputArms(BlueprintNode node) => []; +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/SetNodeExportStrategy.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/SetNodeExportStrategy.cs new file mode 100644 index 00000000..641cbb96 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/SetNodeExportStrategy.cs @@ -0,0 +1,24 @@ +using KitX.Core.Contract.Workflow; + +namespace KitX.Core.Workflow.Blueprint.ExportStrategies; + +public class SetNodeExportStrategy : INodeExportStrategy +{ + public BlueprintNodeType NodeType => BlueprintNodeType.Set; + public bool IsControlFlow => false; + + public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) + { + if (node is not SetNode setNode) return null; + var value = helper.GetInputValue(node, "Value"); + var sourceCode = $"Set(\"{setNode.VarName}\", {value})"; + return new ExpressionStatement + { + Expression = sourceCode, + SourceCode = sourceCode + ";", + LineNumber = 1 + }; + } + + public IEnumerable GetOutputArms(BlueprintNode node) => []; +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/NodeBuilder.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/NodeBuilder.cs index cc35a7e5..00f3aa7d 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/NodeBuilder.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/NodeBuilder.cs @@ -17,12 +17,8 @@ public class NodeBuilder private readonly HashSet _helperNames; // Deferred cross-block edge definitions (resolved after all blocks processed) - private readonly List<(string stmtId, string? trueBlock, string? falseBlock)> _branchDefs = new(); - private readonly List<(string stmtId, string? loopBody, string? loopEnd, string parentBlock)> _loopDefs = new(); private readonly List<(string stmtId, string returnToBlock, string blockName, string? prevStmtId)> _loopBodyEndDefs = new(); - private readonly Dictionary _blockNextBlock = new(); private readonly Dictionary _blockLastStmtId = new(); - private readonly Dictionary _blockEndsWithFlowCtrl = new(); public NodeBuilder(INodeRegistry registry, List helpers) { @@ -63,6 +59,9 @@ private void ProcessBlock(FormattedBlock block, PipelineContext context) BlueprintNode? firstNode = null; bool endsWithFlowCtrl = false; + // Initialize block node ID list for scope tracking + context.BlockNodeIds[block.Name] = new List(); + // MainBlock chains from Entry if (block.Name == context.FormattedScript.MainBlockName) { @@ -76,6 +75,10 @@ private void ProcessBlock(FormattedBlock block, PipelineContext context) if (firstNode == null && node != null) firstNode = node; + // Record node ID in block membership (skip nulls like LoopBodyEnd) + if (node != null) + context.BlockNodeIds[block.Name].Add(node.Id); + endsWithFlowCtrl = stmt.Kind is FormattedStatementKind.Branch or FormattedStatementKind.Loop or FormattedStatementKind.LoopBodyEnd @@ -89,9 +92,9 @@ or FormattedStatementKind.LoopBodyEnd } if (!string.IsNullOrEmpty(block.NextBlockName)) - _blockNextBlock[block.Name] = block.NextBlockName; + context.BlockNextBlock[block.Name] = block.NextBlockName; - _blockEndsWithFlowCtrl[block.Name] = endsWithFlowCtrl; + context.BlockEndsWithFlowCtrl[block.Name] = endsWithFlowCtrl; } // ────────────────────────────────────────────── @@ -130,14 +133,14 @@ or FormattedStatementKind.LoopBodyEnd case FormattedStatementKind.Branch: { var node = ChainNewNode(_registry.Create(BlueprintNodeType.Branch), stmt, context, ref prevNode, ref prevStmtId); - _branchDefs.Add((stmt.StatementId, stmt.TrueBlockName, stmt.FalseBlockName)); + context.BranchDefs.Add((stmt.StatementId, stmt.TrueBlockName, stmt.FalseBlockName)); return node; } case FormattedStatementKind.Loop: { var node = ChainNewNode(_registry.Create(BlueprintNodeType.Loop), stmt, context, ref prevNode, ref prevStmtId); - _loopDefs.Add((stmt.StatementId, stmt.TrueBlockName, stmt.FalseBlockName, blockName)); + context.LoopDefs.Add((stmt.StatementId, stmt.TrueBlockName, stmt.FalseBlockName, blockName)); context.LoopNodesByParent[blockName] = (LoopNode)node!; return node; } @@ -169,14 +172,15 @@ or FormattedStatementKind.LoopBodyEnd if (stmt.FunctionName == "Get") { - // Get nodes: only reuse for LOOP_COND_DUP with matching PubVarTarget - if (stmt.IsLoopConditionDuplication && !string.IsNullOrEmpty(stmt.PubVarTarget)) + // Get nodes: reuse when PubVarTarget matches and reads the same variable. + // This handles both LOOP_COND_DUP and round-trip re-parsed Get statements + // that assign to the same PubVar for the same variable. + if (!string.IsNullOrEmpty(stmt.PubVarTarget) && !string.IsNullOrEmpty(stmt.GetVarName)) { existing = context.PubVarAssignments.Values .FirstOrDefault(p => p.PubVarName == stmt.PubVarTarget - && p.SourceNode.NodeType == BlueprintNodeType.Get); + && p.SourceNode is GetNode gn && gn.VarName == stmt.GetVarName); } - // Non-DUP Get: never reuse → existing stays null } else if (!string.IsNullOrEmpty(stmt.Fingerprint) && context.PubVarAssignments.TryGetValue(stmt.Fingerprint, out var fpExisting)) @@ -261,7 +265,7 @@ or FormattedStatementKind.LoopBodyEnd private void ResolveCrossBlockEdges(PipelineContext context) { // Branch: True → trueBlock first, False → falseBlock first - foreach (var (stmtId, trueBlock, falseBlock) in _branchDefs) + foreach (var (stmtId, trueBlock, falseBlock) in context.BranchDefs) { if (!string.IsNullOrEmpty(trueBlock) && context.BlockFirstNodes.TryGetValue(trueBlock, out var trueFirst)) @@ -293,7 +297,7 @@ private void ResolveCrossBlockEdges(PipelineContext context) } // Loop: LoopBody → body first, LoopEnd → end first - foreach (var (stmtId, loopBody, loopEnd, _) in _loopDefs) + foreach (var (stmtId, loopBody, loopEnd, _) in context.LoopDefs) { if (!string.IsNullOrEmpty(loopBody) && context.BlockFirstNodes.TryGetValue(loopBody, out var bodyFirst)) @@ -341,9 +345,9 @@ private void ResolveCrossBlockEdges(PipelineContext context) } // Sequential fall-through for blocks without flow control endings - foreach (var (blockName, nextBlockName) in _blockNextBlock) + foreach (var (blockName, nextBlockName) in context.BlockNextBlock) { - if (_blockEndsWithFlowCtrl.GetValueOrDefault(blockName)) continue; + if (context.BlockEndsWithFlowCtrl.GetValueOrDefault(blockName)) continue; if (string.IsNullOrEmpty(nextBlockName)) continue; if (!context.BlockFirstNodes.TryGetValue(nextBlockName, out var nextFirst)) continue; if (!_blockLastStmtId.TryGetValue(blockName, out var lastStmtId)) continue; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineAssembler.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineAssembler.cs index 601f85f4..28283b6d 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineAssembler.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineAssembler.cs @@ -63,6 +63,9 @@ public Contract.Workflow.Blueprint Assemble(PipelineContext context) Log.Debug("[PipelineAssembler] Assembled: {NodeCount} nodes, {ConnCount} connections", bp.Nodes.Count, bp.Connections.Count); + // Build block scopes from pipeline context + BuildBlockScopes(bp, context); + return bp; } @@ -153,4 +156,140 @@ public Contract.Workflow.Blueprint Assemble(PipelineContext context) private static BlueprintPin? FindInputPin(BlueprintNode node, string pinName) => node.InputPins.FirstOrDefault(p => p.Name == pinName); + + // ────────────────────────────────────────────── + // Block Scope Construction + // ────────────────────────────────────────────── + + private void BuildBlockScopes(Contract.Workflow.Blueprint bp, PipelineContext ctx) + { + var mainBlockName = ctx.FormattedScript.MainBlockName; + + // Identify which blocks are Branch/Loop targets (have owners) + var ownedBlockNames = new HashSet(); + foreach (var (_, trueBlock, falseBlock) in ctx.BranchDefs) + { + if (trueBlock != null) ownedBlockNames.Add(trueBlock); + if (falseBlock != null) ownedBlockNames.Add(falseBlock); + } + foreach (var (_, loopBody, loopEnd, _) in ctx.LoopDefs) + { + if (loopBody != null) ownedBlockNames.Add(loopBody); + if (loopEnd != null) ownedBlockNames.Add(loopEnd); + } + + // Build temporary scopes for all formatted blocks + var tempScopes = new Dictionary nodeIds, string? nextBlock)>(); + foreach (var block in ctx.FormattedScript.Blocks) + { + var scope = new BlueprintBlockScope + { + Name = block.Name, + IsMainBlock = block.Name == mainBlockName, + NextBlockName = block.NextBlockName, + }; + + var nodeIds = new List(); + if (ctx.BlockNodeIds.TryGetValue(block.Name, out var ids)) + nodeIds = new List(ids); + + tempScopes[block.Name] = (scope, nodeIds, block.NextBlockName); + } + + // Merge sequential (non-owned) blocks into their parent chain + // A block is "sequential" if it's not the main block and not owned by a Branch/Loop + var merged = new HashSet(); + foreach (var kvp in tempScopes.ToList()) + { + var (scope, nodeIds, nextBlock) = kvp.Value; + if (scope.IsMainBlock || ownedBlockNames.Contains(scope.Name)) + continue; + + // Find the root ancestor: follow NextBlockName chain upward to find the named/owned block + var ancestorName = FindAncestor(tempScopes, kvp.Key, ownedBlockNames, mainBlockName); + if (ancestorName != null && ancestorName != kvp.Key) + { + // Merge this scope's nodes into the ancestor + var (ancestorScope, ancestorNodeIds, _) = tempScopes[ancestorName]; + ancestorNodeIds.AddRange(nodeIds); + merged.Add(kvp.Key); + } + } + + // Remove merged scopes and add remaining to Blueprint + foreach (var kvp in tempScopes) + { + if (merged.Contains(kvp.Key)) continue; + + var (scope, nodeIds, _) = kvp.Value; + scope.NodeIds = nodeIds; + bp.BlockScopes.Add(scope); + } + + // Build scopesByName for ownership assignment + var scopesByName = bp.BlockScopes.ToDictionary(s => s.Name, s => s); + + // Assign ownership from Branch definitions + foreach (var (stmtId, trueBlock, falseBlock) in ctx.BranchDefs) + { + if (!ctx.NodeByStatementId.TryGetValue(stmtId, out var branchNode)) continue; + + if (trueBlock != null && scopesByName.TryGetValue(trueBlock, out var trueScope)) + { + trueScope.OwnerNodeId = branchNode.Id; + trueScope.OwnerArmName = "True"; + } + if (falseBlock != null && scopesByName.TryGetValue(falseBlock, out var falseScope)) + { + falseScope.OwnerNodeId = branchNode.Id; + falseScope.OwnerArmName = "False"; + } + } + + // Assign ownership from Loop definitions + foreach (var (stmtId, loopBody, loopEnd, _) in ctx.LoopDefs) + { + if (!ctx.NodeByStatementId.TryGetValue(stmtId, out var loopNode)) continue; + + if (loopBody != null && scopesByName.TryGetValue(loopBody, out var bodyScope)) + { + bodyScope.OwnerNodeId = loopNode.Id; + bodyScope.OwnerArmName = "LoopBody"; + } + if (loopEnd != null && scopesByName.TryGetValue(loopEnd, out var endScope)) + { + endScope.OwnerNodeId = loopNode.Id; + endScope.OwnerArmName = "LoopEnd"; + } + } + + Log.Debug("[PipelineAssembler] Built {ScopeCount} block scopes (merged {MergedCount} intermediate)", + bp.BlockScopes.Count, merged.Count); + } + + /// + /// Walks the NextBlockName chain backward from a block to find its named/owned ancestor. + /// Returns null if the block is orphaned. + /// + private string? FindAncestor( + Dictionary nodeIds, string? nextBlock)> tempScopes, + string blockName, + HashSet ownedBlockNames, + string mainBlockName) + { + // Follow the chain: find which block has NextBlockName pointing to our block + foreach (var kvp in tempScopes) + { + var (_, _, nextBlock) = kvp.Value; + if (nextBlock == blockName) + { + // The parent is this block + if (kvp.Key == mainBlockName || ownedBlockNames.Contains(kvp.Key)) + return kvp.Key; + // Recurse to find the grandparent + return FindAncestor(tempScopes, kvp.Key, ownedBlockNames, mainBlockName); + } + } + return null; + } } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineContext.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineContext.cs index f7b7efb8..eb8ff192 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineContext.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineContext.cs @@ -28,6 +28,13 @@ public class PipelineContext public Dictionary BlockFirstNodes { get; set; } = new(); public List ExecEdges { get; set; } = new(); + // --- Block scope tracking (populated by NodeBuilder, consumed by Assembler) --- + public Dictionary> BlockNodeIds { get; set; } = new(); + public Dictionary BlockNextBlock { get; set; } = new(); + public Dictionary BlockEndsWithFlowCtrl { get; set; } = new(); + public List<(string stmtId, string? trueBlock, string? falseBlock)> BranchDefs { get; set; } = new(); + public List<(string stmtId, string? loopBody, string? loopEnd, string parentBlock)> LoopDefs { get; set; } = new(); + // --- Phase 4 output --- public List DataEdges { get; set; } = new(); diff --git a/KitX Standard b/KitX Standard index 46442b16..302d65fe 160000 --- a/KitX Standard +++ b/KitX Standard @@ -1 +1 @@ -Subproject commit 46442b16d697b298c57e8ae551c9467b292596f7 +Subproject commit 302d65fe3c446ca62c50607022fb50115d316ee7 From 24133cb1423eaced097c1bca9175f64123481988 Mon Sep 17 00:00:00 2001 From: StarInk Date: Tue, 7 Apr 2026 22:29:17 +0200 Subject: [PATCH 033/110] =?UTF-8?q?=F0=9F=92=BE=20Feat(Blueprint):=20Enhan?= =?UTF-8?q?ce=20reverse=20conversion=20pipeline=20and=20PubVar=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implemented BlockScriptAssembler to generate final BlockScript from constructed block definitions. - Added BlueprintAnalyzer to analyze Blueprint, classify connections, and auto-assign missing PubVar names. - Introduced ConditionDuplicator to duplicate loop condition evaluation statements in loop bodies. - Created ReverseConversionContext to maintain state across reverse conversion phases. - Refactored PubVar handling in various components to improve consistency and avoid conflicts. - Updated ExprUtils to include methods for extracting and generating PubVar names. - Modified ScriptFormatter to accommodate new PubVar handling logic. - Cleaned up DataEdgeBuilder to streamline PubVar source connection logic. --- .../KitX.Core.BluePrint.Test/Program.cs | 116 +++ .../BlueprintToBlockScriptConverter.cs | 864 +++--------------- .../CallHelperNodeExportStrategy.cs | 5 +- .../Blueprint/Pipeline/DataEdgeBuilder.cs | 20 +- .../Workflow/Blueprint/Pipeline/ExprUtils.cs | 45 +- .../Blueprint/Pipeline/ScriptFormatter.cs | 25 +- .../ReversePipeline/BlockScriptAssembler.cs | 75 ++ .../ReversePipeline/BlueprintAnalyzer.cs | 112 +++ .../ReversePipeline/ConditionDuplicator.cs | 126 +++ .../ReverseConversionContext.cs | 53 ++ KitX Standard | 2 +- 11 files changed, 645 insertions(+), 798 deletions(-) create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ReversePipeline/BlockScriptAssembler.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ReversePipeline/BlueprintAnalyzer.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ReversePipeline/ConditionDuplicator.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ReversePipeline/ReverseConversionContext.cs diff --git a/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs b/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs index 2a9a0d41..be5dd5ee 100644 --- a/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs +++ b/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs @@ -88,6 +88,41 @@ public static void Main(string[] args) Console.WriteLine("└──────────────────────────────────────────┘\n"); RunManualBlueprintTest(reverseConverter, "Test E"); + + // ── Test F: Pure sequential flow (no Branch/Loop) ── + Console.WriteLine("\n┌──────────────────────────────────────────┐"); + Console.WriteLine("│ Test F: Pure sequential flow │"); + Console.WriteLine("└──────────────────────────────────────────┘\n"); + + RunTest(converter, GetSequentialScript(), helpers, "Test F"); + + // ── Test G: No ConstBlock ── + Console.WriteLine("\n┌──────────────────────────────────────────┐"); + Console.WriteLine("│ Test G: No ConstBlock │"); + Console.WriteLine("└──────────────────────────────────────────┘\n"); + + RunTest(converter, GetNoConstScript(), helpers, "Test G"); + + // ── Test H: Break inside Loop ── + Console.WriteLine("\n┌──────────────────────────────────────────┐"); + Console.WriteLine("│ Test H: Break inside Loop │"); + Console.WriteLine("└──────────────────────────────────────────┘\n"); + + RunRoundTripTest(converter, reverseConverter, GetBreakScript(), helpers, "Test H"); + + // ── Test I: Nested Loop ── + Console.WriteLine("\n┌──────────────────────────────────────────┐"); + Console.WriteLine("│ Test I: Nested Loop │"); + Console.WriteLine("└──────────────────────────────────────────┘\n"); + + RunRoundTripTest(converter, reverseConverter, GetNestedLoopScript(), helpers, "Test I"); + + // ── Test J: Single statement ── + Console.WriteLine("\n┌──────────────────────────────────────────┐"); + Console.WriteLine("│ Test J: Single statement (minimal) │"); + Console.WriteLine("└──────────────────────────────────────────┘\n"); + + RunTest(converter, GetSingleStatementScript(), helpers, "Test J"); } private static void RunTest(BlockScriptToBlueprintConverter converter, @@ -468,4 +503,85 @@ private static void RunManualBlueprintTest( Console.WriteLine($" {ex.StackTrace}"); } } + + // ────────────────────────────────────────────── + // Test F: Pure sequential flow (no Branch/Loop) + // ────────────────────────────────────────────── + private static string GetSequentialScript() => @"#ConstBlock +string greeting = ""Hello""; +string name = ""World""; + +#MainBlock +Print(greeting); +Print(name); +Set(""counter"", 0); +Print(""Done"");"; + + // ────────────────────────────────────────────── + // Test G: No ConstBlock + // ────────────────────────────────────────────── + private static string GetNoConstScript() => @"#MainBlock +Print(""No constants needed""); +Set(""x"", 42); +Print(""Done"");"; + + // ────────────────────────────────────────────── + // Test H: Break inside Loop + // ────────────────────────────────────────────── + private static string GetBreakScript() => @"#ConstBlock +int maxIter = 10; +int target = 3; + +#MainBlock +Set(""i"", 0); +NextBlock = Loop(HelperFuncCompare(""BLE"", Get(""i""), maxIter), ""LoopBody"", ""AfterLoop""); + +#Block LoopBody +NextBlock = Branch( + HelperFuncCompare(""BEQ"", Get(""i""), target), + ""BreakBlock"", + ""ContinueBlock"" +); + +#Block BreakBlock +Break(); + +#Block ContinueBlock +Set(""i"", HelperFuncAdd(Get(""i""), 1)); +NextBlock = LoopBodyEnd(""MainBlock""); + +#Block AfterLoop +Print(""Loop finished with break"");"; + + // ────────────────────────────────────────────── + // Test I: Nested Loop + // ────────────────────────────────────────────── + private static string GetNestedLoopScript() => @"#ConstBlock +int outerMax = 2; +int innerMax = 3; + +#MainBlock +Set(""outer"", 0); +NextBlock = Loop(HelperFuncCompare(""BLT"", Get(""outer""), outerMax), ""OuterBody"", ""Done""); + +#Block OuterBody +Set(""inner"", 0); +NextBlock = Loop(HelperFuncCompare(""BLT"", Get(""inner""), innerMax), ""InnerBody"", ""OuterEnd""); + +#Block InnerBody +Set(""inner"", HelperFuncAdd(Get(""inner""), 1)); +NextBlock = LoopBodyEnd(""OuterBody""); + +#Block OuterEnd +Set(""outer"", HelperFuncAdd(Get(""outer""), 1)); +NextBlock = LoopBodyEnd(""MainBlock""); + +#Block Done +Print(""Nested loops done"");"; + + // ────────────────────────────────────────────── + // Test J: Single statement (minimal) + // ────────────────────────────────────────────── + private static string GetSingleStatementScript() => @"#MainBlock +Print(""Hello, World!"");"; } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintToBlockScriptConverter.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintToBlockScriptConverter.cs index 760006bc..0712b800 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintToBlockScriptConverter.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintToBlockScriptConverter.cs @@ -1,19 +1,33 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Text; using KitX.Core.Contract.Workflow; +using KitX.Core.Workflow.Blueprint.Pipeline; +using KitX.Core.Workflow.Blueprint.ReversePipeline; using Serilog; namespace KitX.Core.Workflow.Blueprint; /// /// Converts Blueprint back to a fully-expanded BlockScript source code. -/// Produces output equivalent to what ScriptFormatter (Phase 2 of the forward pipeline) generates. +/// Thin orchestrator that delegates to pipeline phases: +/// Phase 1: BlueprintAnalyzer — data index construction +/// Phase 2: Walk execution flow (BlockScopes or topology path) +/// Phase 3: ConditionDuplicator — loop condition duplication +/// Phase 4: BlockScriptAssembler — source code assembly +/// Also implements INodeExportHelper for use by Export Strategies. /// public class BlueprintToBlockScriptConverter : IBlueprintToBlockScriptConverter, INodeExportHelper { private readonly Dictionary _strategies; + private readonly BlueprintAnalyzer _analyzer = new(); + private readonly ConditionDuplicator _conditionDuplicator = new(); + private readonly BlockScriptAssembler _assembler = new(); + + /// + /// Current conversion context — set during ConvertToBlockScript, accessible by INodeExportHelper methods. + /// + private ReverseConversionContext? _currentCtx; public BlueprintToBlockScriptConverter(IEnumerable strategies) { @@ -29,36 +43,28 @@ public BlueprintToBlockScriptConverter(IEnumerable strategi /// public string Convert(Contract.Workflow.Blueprint blueprint) - { - return ConvertToBlockScript(blueprint).SourceCode; - } + => ConvertToBlockScript(blueprint).SourceCode; /// public BlockScript ConvertToBlockScript(Contract.Workflow.Blueprint blueprint) { - // Fast path: use stored block scopes when available if (blueprint.BlockScopes.Count > 0) return ConvertWithBlockScopes(blueprint); - - // Fallback: topology-based conversion return ConvertWithTopology(blueprint); } - /// - /// Fast-path conversion using stored block scope information. - /// Block names and membership are read directly from the Blueprint. - /// + // ────────────────────────────────────────────── + // Conversion paths + // ────────────────────────────────────────────── + private BlockScript ConvertWithBlockScopes(Contract.Workflow.Blueprint blueprint) { Blueprint = blueprint; - var ctx = new ReverseConversionContext - { - Blueprint = blueprint, - Script = new BlockScript() - }; + var ctx = new ReverseConversionContext { Blueprint = blueprint, Script = new BlockScript() }; + _currentCtx = ctx; - // Phase 1: Analyze blueprint — build data indexes (same as topology path) - AnalyzeBlueprint(ctx); + // Phase 1 + _analyzer.Analyze(ctx); // Build scopesByName lookup foreach (var scope in blueprint.BlockScopes) @@ -86,8 +92,6 @@ private BlockScript ConvertWithBlockScopes(Contract.Workflow.Blueprint blueprint if (stmt != null) { block.Statements.Add(stmt); - - // Track control flow nodes for block name resolution if (stmt is FlowControlStatement flow) { ctx.ControlFlowMap[node.Id] = flow; @@ -96,8 +100,6 @@ private BlockScript ConvertWithBlockScopes(Contract.Workflow.Blueprint blueprint } } } - - // (LoopBodyEnd is NOT auto-inserted here — it's detected from exec connections below) } // Resolve control flow block names from stored ownership @@ -105,60 +107,44 @@ private BlockScript ConvertWithBlockScopes(Contract.Workflow.Blueprint blueprint { if (scope.OwnerNodeId == null) continue; if (!ctx.ControlFlowMap.TryGetValue(scope.OwnerNodeId, out var flow)) continue; - if (scope.OwnerArmName == "True" || scope.OwnerArmName == "LoopBody") flow.TrueBlockName = scope.Name; else if (scope.OwnerArmName == "False" || scope.OwnerArmName == "LoopEnd") flow.FalseBlockName = scope.Name; } - // Regenerate flow control source code with resolved block names foreach (var flow in ctx.ControlFlowMap.Values) - { - if (flow.ControlType == FlowControlType.Branch) - flow.SourceCode = $"NextBlock = Branch({flow.ConditionExpression}, \"{flow.TrueBlockName}\", \"{flow.FalseBlockName}\");"; - else if (flow.ControlType == FlowControlType.Loop) - flow.SourceCode = $"NextBlock = Loop({flow.ConditionExpression}, \"{flow.TrueBlockName}\", \"{flow.FalseBlockName}\");"; - } + flow.RegenerateSourceCode(); - // Detect LoopBodyEnd: scopes whose last node's Exec output connects back to a Loop node DetectAndInsertLoopBodyEnds(blueprint, ctx); - // Phase 4: Assemble source code (same as topology path) - AssembleBlockScript(ctx); - + // Phase 4 + _assembler.Assemble(ctx); return ctx.Script; } - /// - /// Fallback topology-based conversion for legacy blueprints without BlockScopes. - /// private BlockScript ConvertWithTopology(Contract.Workflow.Blueprint blueprint) { Blueprint = blueprint; - var ctx = new ReverseConversionContext - { - Blueprint = blueprint, - Script = new BlockScript() - }; + var ctx = new ReverseConversionContext { Blueprint = blueprint, Script = new BlockScript() }; + _currentCtx = ctx; - // Phase 1: Analyze blueprint — build indexes - AnalyzeBlueprint(ctx); + // Phase 1 + _analyzer.Analyze(ctx); - // Phase 2: Walk execution flow — generate expanded statements + // Phase 2: Walk execution flow WalkExecutionFlow(ctx); - // Phase 3: Duplicate loop conditions before LoopBodyEnd - DuplicateLoopConditions(ctx); + // Phase 3: Duplicate loop conditions + _conditionDuplicator.Duplicate(ctx); // Phase 4: Assemble source code - AssembleBlockScript(ctx); - + _assembler.Assemble(ctx); return ctx.Script; } // ────────────────────────────────────────────── - // INodeExportHelper (backward compat for strategies) + // INodeExportHelper // ────────────────────────────────────────────── /// @@ -168,13 +154,13 @@ public string GetInputValue(BlueprintNode node, string pinName) if (pin == null) return string.Empty; var dataConn = Blueprint.Connections.FirstOrDefault(c => c.TargetPinId == pin.Id); - if (dataConn == null) return pin.DefaultValue ?? string.Empty; + if (dataConn == null) return FormatLiteralValue(pin.DefaultValue ?? string.Empty, _currentCtx); var sourceNode = Blueprint.GetNodeById(dataConn.SourceNodeId); if (sourceNode is ConstNode constNode) return constNode.ConstName; - return dataConn.PubVarName ?? pin.DefaultValue ?? string.Empty; + return dataConn.PubVarName ?? FormatLiteralValue(pin.DefaultValue ?? string.Empty, _currentCtx); } /// @@ -184,142 +170,30 @@ public string GetInputArgs(BlueprintNode node) foreach (var pin in node.InputPins) { if (pin.Name != "Exec") - { args.Add(GetInputValue(node, pin.Name)); - } } return string.Join(", ", args); } // ────────────────────────────────────────────── - // Phase 1: Analyze Blueprint - // ────────────────────────────────────────────── - - private void AnalyzeBlueprint(ReverseConversionContext ctx) - { - var bp = ctx.Blueprint; - - // Build node lookup - foreach (var node in bp.Nodes) - ctx.NodeById[node.Id] = node; - - // Classify connections and build data indexes - foreach (var conn in bp.Connections) - { - var sourceNode = bp.GetNodeById(conn.SourceNodeId); - if (sourceNode == null) continue; - - var sourcePin = sourceNode.GetPinById(conn.SourcePinId); - if (sourcePin == null) continue; - - if (sourcePin.Type == PinType.Execution) - { - ctx.ExecConnections.Add(conn); - } - else - { - ctx.DataConnections.Add(conn); - - // Build input data map - var targetNode = bp.GetNodeById(conn.TargetNodeId); - if (targetNode != null) - { - var targetPin = targetNode.GetPinById(conn.TargetPinId); - if (targetPin != null) - { - ctx.InputDataMap[(conn.TargetNodeId, targetPin.Name)] = new DataEdgeInfo - { - SourceNode = sourceNode, - SourcePinName = sourcePin.Name, - SourcePin = sourcePin, - PubVarName = conn.PubVarName, - Connection = conn - }; - - // Track consumed outputs - ctx.ConsumedOutputs.Add((conn.SourceNodeId, sourcePin.Name)); - } - } - } - } - - // Assign PubVar names where needed but missing - foreach (var conn in ctx.DataConnections) - { - if (!string.IsNullOrEmpty(conn.PubVarName)) continue; - - var sourceNode = bp.GetNodeById(conn.SourceNodeId); - if (sourceNode == null) continue; - - // ConstNode references don't need PubVar - if (sourceNode.NodeType == BlueprintNodeType.Const) continue; - - // GetNode, CallNode, CallHelperNode outputs need PubVar if consumed downstream - if (sourceNode.NodeType is BlueprintNodeType.Get or BlueprintNodeType.Call - or BlueprintNodeType.CallHelper) - { - var sourcePin = sourceNode.GetPinById(conn.SourcePinId); - if (sourcePin == null) continue; - - var pubVar = GeneratePubVarName(ctx); - conn.PubVarName = pubVar; - ctx.AutoPubVars.Add(pubVar); - - // Update the InputDataMap entry - var targetNode = bp.GetNodeById(conn.TargetNodeId); - if (targetNode != null) - { - var targetPin = targetNode.GetPinById(conn.TargetPinId); - if (targetPin != null && ctx.InputDataMap.TryGetValue( - (conn.TargetNodeId, targetPin.Name), out var info)) - { - info.PubVarName = pubVar; - } - } - - Log.Debug("[BlueprintToScript] Auto-assigned PubVar {PubVar} for {NodeType}.{Pin}", - pubVar, sourceNode.NodeType, sourcePin.Name); - } - } - - // Collect all PubVar names - foreach (var conn in ctx.DataConnections) - { - if (!string.IsNullOrEmpty(conn.PubVarName) && !ctx.AllPubVars.Contains(conn.PubVarName)) - ctx.AllPubVars.Add(conn.PubVarName); - } - - Log.Debug("[BlueprintToScript] Phase 1 done: {Nodes} nodes, {Exec} exec conns, {Data} data conns, {PubVars} pubvars", - ctx.NodeById.Count, ctx.ExecConnections.Count, ctx.DataConnections.Count, ctx.AllPubVars.Count); - } - - // ────────────────────────────────────────────── - // Phase 2: Walk Execution Flow + // Phase 2: Walk Execution Flow (topology path) // ────────────────────────────────────────────── private void WalkExecutionFlow(ReverseConversionContext ctx) { - var bp = ctx.Blueprint; - var entryNode = bp.Nodes.FirstOrDefault(n => n.NodeType == BlueprintNodeType.Entry); + var entryNode = ctx.Blueprint.Nodes.FirstOrDefault(n => n.NodeType == BlueprintNodeType.Entry); if (entryNode == null) { Log.Warning("[BlueprintToScript] No Entry node found"); return; } - // Main block - var mainBlock = new BlockDefinition - { - Type = BlockType.MainBlock, - Name = "MainBlock" - }; + var mainBlock = new BlockDefinition { Type = BlockType.MainBlock, Name = "MainBlock" }; ctx.Script.MainBlock = mainBlock; - // Walk from entry var visited = new HashSet(); WalkNode(entryNode, mainBlock, ctx, visited, loopbackTargetId: null); - // Process control flow sub-graphs ProcessSubGraphs(ctx); } @@ -329,129 +203,84 @@ private void WalkNode(BlueprintNode node, BlockDefinition currentBlock, if (visited.Contains(node.Id)) return; visited.Add(node.Id); - // If this node is the loopback target (we've looped back to the Loop node), insert LoopBodyEnd if (loopbackTargetId != null && node.Id == loopbackTargetId) { - currentBlock.Statements.Add(new FlowControlStatement - { - ControlType = FlowControlType.LoopBodyEnd, - SourceCode = "LoopBodyEnd();", - LineNumber = 1 - }); + currentBlock.Statements.Add(CreateLoopBodyEndStatement()); return; } - // Generate statement for this node var stmt = GenerateStatement(node, ctx); if (stmt != null) { currentBlock.Statements.Add(stmt); - - // Track flow control for sub-graph processing if (stmt is FlowControlStatement flow) { ctx.ControlFlowMap[node.Id] = flow; - - // Record loop condition info for Phase 3 if (flow.ControlType == FlowControlType.Loop) - { ctx.LoopNodes[node.Id] = node; - } } } - // Check if this is a control flow node — stop main flow walk if (node.NodeType is BlueprintNodeType.Branch or BlueprintNodeType.Loop) { ctx.PendingControlFlowNodes.Add(node); return; } - // Follow exec output var execOut = node.OutputPins.FirstOrDefault(p => p.Name == "Exec"); if (execOut == null) { - // Dead end inside a loop body → insert LoopBodyEnd if (loopbackTargetId != null) - { - currentBlock.Statements.Add(new FlowControlStatement - { - ControlType = FlowControlType.LoopBodyEnd, - SourceCode = "LoopBodyEnd();", - LineNumber = 1 - }); - } + currentBlock.Statements.Add(CreateLoopBodyEndStatement()); return; } var execConn = ctx.ExecConnections.FirstOrDefault(c => c.SourcePinId == execOut.Id); if (execConn == null) { - // Dead end inside a loop body → insert LoopBodyEnd if (loopbackTargetId != null) - { - currentBlock.Statements.Add(new FlowControlStatement - { - ControlType = FlowControlType.LoopBodyEnd, - SourceCode = "LoopBodyEnd();", - LineNumber = 1 - }); - } + currentBlock.Statements.Add(CreateLoopBodyEndStatement()); return; } var nextNode = ctx.Blueprint.GetNodeById(execConn.TargetNodeId); if (nextNode == null) return; - // Check for loopback (next node is the loop we're looping back to) if (loopbackTargetId != null && nextNode.Id == loopbackTargetId) { - currentBlock.Statements.Add(new FlowControlStatement - { - ControlType = FlowControlType.LoopBodyEnd, - SourceCode = "LoopBodyEnd();", - LineNumber = 1 - }); + currentBlock.Statements.Add(CreateLoopBodyEndStatement()); return; } WalkNode(nextNode, currentBlock, ctx, visited, loopbackTargetId); } + // ────────────────────────────────────────────── + // Statement generation (strategy dispatch) + // ────────────────────────────────────────────── + + private static FlowControlStatement CreateLoopBodyEndStatement(string? returnTo = null) + { + var stmt = new FlowControlStatement + { + ControlType = FlowControlType.LoopBodyEnd, + LineNumber = 1, + LoopBodyEndReturnTo = returnTo + }; + stmt.RegenerateSourceCode(); + return stmt; + } + /// - /// Generates a BlockStatement for the given node in expanded format. + /// Generates a BlockStatement for the given node using Export Strategies, + /// with post-processing for PubVar assignments. /// private BlockStatement? GenerateStatement(BlueprintNode node, ReverseConversionContext ctx) { switch (node.NodeType) { case BlueprintNodeType.Entry: - return null; // Entry nodes produce no statement - - case BlueprintNodeType.Print: - return GeneratePrintStatement(node, ctx); - - case BlueprintNodeType.Pause: - return GeneratePauseStatement(node, ctx); - - case BlueprintNodeType.Set: - return GenerateSetStatement(node, ctx); - - case BlueprintNodeType.Get: - return GenerateGetStatement(node, ctx); - - case BlueprintNodeType.Call: - return GenerateCallStatement(node, ctx); - - case BlueprintNodeType.CallHelper: - return GenerateCallHelperStatement(node, ctx); - - case BlueprintNodeType.Branch: - return GenerateBranchStatement(node, ctx); - - case BlueprintNodeType.Loop: - return GenerateLoopStatement(node, ctx); - + return null; case BlueprintNodeType.Break: return new FlowControlStatement { @@ -459,262 +288,96 @@ private void WalkNode(BlueprintNode node, BlockDefinition currentBlock, SourceCode = "Break();", LineNumber = 1 }; - + case BlueprintNodeType.Get: + return GenerateGetStatementViaStrategy(node, ctx); default: - Log.Warning("[BlueprintToScript] Unhandled node type: {NodeType}", node.NodeType); - return null; + break; } - } - - private BlockStatement? GeneratePrintStatement(BlueprintNode node, ReverseConversionContext ctx) - { - var value = ResolveInputValue(node, "Value", ctx); - var sourceCode = $"Print({value});"; - return new ExpressionStatement - { - Expression = $"Print({value})", - SourceCode = sourceCode, - LineNumber = 1 - }; - } - - private BlockStatement? GeneratePauseStatement(BlueprintNode node, ReverseConversionContext ctx) - { - var ms = ResolveInputValue(node, "Milliseconds", ctx); - var sourceCode = $"Pause({ms});"; - return new ExpressionStatement - { - Expression = $"Pause({ms})", - SourceCode = sourceCode, - LineNumber = 1 - }; - } - - private BlockStatement? GenerateSetStatement(BlueprintNode node, ReverseConversionContext ctx) - { - if (node is not SetNode setNode) return null; - var value = ResolveInputValue(node, "Value", ctx); - var sourceCode = $"Set(\"{setNode.VarName}\", {value});"; - return new ExpressionStatement - { - Expression = $"Set(\"{setNode.VarName}\", {value})", - SourceCode = sourceCode, - LineNumber = 1 - }; - } - - private BlockStatement? GenerateGetStatement(BlueprintNode node, ReverseConversionContext ctx) - { - if (node is not GetNode getNode) return null; - // Only generate statement if Value output is consumed - var valuePin = node.OutputPins.FirstOrDefault(p => p.Name == "Value"); - if (valuePin == null || !ctx.ConsumedOutputs.Contains((node.Id, "Value"))) + if (!_strategies.TryGetValue(node.NodeType, out var strategy)) { - // Not consumed — skip (invisible in exec flow) + Log.Warning("[BlueprintToScript] Unhandled node type: {NodeType}", node.NodeType); return null; } - // Find the PubVar assigned to this Get's output - var pubVar = FindOutputPubVar(node, "Value", ctx); - if (pubVar == null) return null; - - var sourceCode = $"{pubVar} = Get(\"{getNode.VarName}\");"; - return new ExpressionStatement - { - Expression = $"Get(\"{getNode.VarName}\")", - SourceCode = sourceCode, - LineNumber = 1 - }; - } - - private BlockStatement? GenerateCallStatement(BlueprintNode node, ReverseConversionContext ctx) - { - if (node is not CallNode callNode) return null; - - // Resolve arguments (all non-Exec input pins) - var args = ResolveAllArgs(node, ctx); - var funcRef = string.IsNullOrEmpty(callNode.PluginName) - ? callNode.FunctionName - : $"{callNode.PluginName}.{callNode.FunctionName}"; + var stmt = strategy.ToStatement(node, this); + if (stmt == null) return null; - // Check if Return output is consumed → needs PubVar assignment - var returnPin = node.OutputPins.FirstOrDefault(p => p.Name == "Return"); - bool hasReturn = returnPin != null && ctx.ConsumedOutputs.Contains((node.Id, "Return")); - - if (hasReturn) + // Post-process: add PubVar prefix for Call/CallHelper with consumed Return + if (node.NodeType is BlueprintNodeType.Call or BlueprintNodeType.CallHelper + && stmt is ExpressionStatement exprStmt) { - var pubVar = FindOutputPubVar(node, "Return", ctx); - var sourceCode = $"{pubVar} = {funcRef}({args});"; - return new ExpressionStatement + var returnPin = node.OutputPins.FirstOrDefault(p => p.Name == "Return"); + bool hasReturn = returnPin != null && ctx.ConsumedOutputs.Contains((node.Id, "Return")); + if (hasReturn) { - Expression = $"{funcRef}({args})", - SourceCode = sourceCode, - LineNumber = 1 - }; - } - else - { - var sourceCode = $"{funcRef}({args});"; - return new ExpressionStatement - { - Expression = $"{funcRef}({args})", - SourceCode = sourceCode, - LineNumber = 1 - }; + var pubVar = FindOutputPubVar(node, "Return", ctx); + if (pubVar != null) + exprStmt.SourceCode = $"{pubVar} = {exprStmt.Expression};"; + } } - } - - private BlockStatement? GenerateCallHelperStatement(BlueprintNode node, ReverseConversionContext ctx) - { - if (node is not CallHelperNode helperNode) return null; - - // Resolve arguments - var args = ResolveAllArgs(node, ctx); - var funcName = helperNode.HelperFunctionName; - - // Check if Return output is consumed → needs PubVar assignment - var returnPin = node.OutputPins.FirstOrDefault(p => p.Name == "Return"); - bool hasReturn = returnPin != null && ctx.ConsumedOutputs.Contains((node.Id, "Return")); - if (hasReturn) - { - var pubVar = FindOutputPubVar(node, "Return", ctx); - var sourceCode = $"{pubVar} = {funcName}({args});"; - return new ExpressionStatement - { - Expression = $"{funcName}({args})", - SourceCode = sourceCode, - LineNumber = 1 - }; - } - else - { - var sourceCode = $"{funcName}({args});"; - return new ExpressionStatement - { - Expression = $"{funcName}({args})", - SourceCode = sourceCode, - LineNumber = 1 - }; - } + return stmt; } - private BlockStatement GenerateBranchStatement(BlueprintNode node, ReverseConversionContext ctx) + private BlockStatement? GenerateGetStatementViaStrategy(BlueprintNode node, ReverseConversionContext ctx) { - var condition = ResolveInputValue(node, "Condition", ctx); + if (!ctx.ConsumedOutputs.Contains((node.Id, "Value"))) + return null; - // Block names will be filled during sub-graph processing - return new FlowControlStatement - { - ControlType = FlowControlType.Branch, - ConditionExpression = condition, - TrueBlockName = string.Empty, // filled later - FalseBlockName = string.Empty, // filled later - SourceCode = $"NextBlock = Branch({condition}, \"\", \"\");", - LineNumber = 1 - }; - } + var pubVar = FindOutputPubVar(node, "Value", ctx); + if (pubVar == null) return null; - private BlockStatement GenerateLoopStatement(BlueprintNode node, ReverseConversionContext ctx) - { - var condition = ResolveInputValue(node, "Condition", ctx); + if (!_strategies.TryGetValue(BlueprintNodeType.Get, out var strategy)) + return null; - return new FlowControlStatement + var stmt = strategy.ToStatement(node, this); + if (stmt is ExpressionStatement exprStmt) { - ControlType = FlowControlType.Loop, - ConditionExpression = condition, - TrueBlockName = string.Empty, // filled later - FalseBlockName = string.Empty, // filled later - SourceCode = $"NextBlock = Loop({condition}, \"\", \"\");", - LineNumber = 1 - }; + exprStmt.SourceCode = $"{pubVar} = {exprStmt.Expression};"; + return exprStmt; + } + return stmt; } // ────────────────────────────────────────────── // Input Resolution // ────────────────────────────────────────────── - /// - /// Resolves the value for an input pin by tracing data connections. - /// private string ResolveInputValue(BlueprintNode node, string pinName, ReverseConversionContext ctx) { var pin = node.InputPins.FirstOrDefault(p => p.Name == pinName); if (pin == null) return string.Empty; - // Check if there's a data connection to this pin if (ctx.InputDataMap.TryGetValue((node.Id, pin.Name), out var info)) { - // Source is ConstNode → use ConstName if (info.SourceNode is ConstNode constNode) return constNode.ConstName; - - // Has PubVarName → use it if (!string.IsNullOrEmpty(info.PubVarName)) return info.PubVarName; } - // No connection → use default value var defaultValue = pin.DefaultValue ?? string.Empty; return FormatLiteralValue(defaultValue, ctx); } - /// - /// Formats a literal value for output, adding quotes to strings when needed. - /// - private string FormatLiteralValue(string value, ReverseConversionContext? ctx = null) + private static string FormatLiteralValue(string value, ReverseConversionContext? ctx = null) { if (string.IsNullOrEmpty(value)) return value; - - // Already quoted if (value.StartsWith("\"")) return value; - - // PubVar reference - if (value.StartsWith("vaaa") && value.Length >= 8) return value; - - // Numeric literal + if (ctx != null && ctx.AllPubVars.Contains(value)) return value; if (int.TryParse(value, out _) || double.TryParse(value, out _)) return value; - - // Boolean literal if (value == "true" || value == "false") return value; - - // Get/Set/Helper expressions if (value.Contains("(")) return value; - - // Known ConstName reference if (ctx != null && ctx.Blueprint.Nodes.OfType().Any(c => c.ConstName == value)) return value; - - // Default: treat as string, add quotes return $"\"{value}\""; } - /// - /// Resolves all non-Exec input arguments, returning comma-separated string. - /// - private string ResolveAllArgs(BlueprintNode node, ReverseConversionContext ctx) - { - var args = new List(); - foreach (var pin in node.InputPins) - { - if (pin.Name != "Exec") - { - args.Add(ResolveInputValue(node, pin.Name, ctx)); - } - } - return string.Join(", ", args); - } - - /// - /// Finds the PubVar name assigned to a node's output pin. - /// Looks through all data connections from this pin. - /// private string? FindOutputPubVar(BlueprintNode node, string pinName, ReverseConversionContext ctx) { var pin = node.OutputPins.FirstOrDefault(p => p.Name == pinName); if (pin == null) return null; - var conn = ctx.DataConnections.FirstOrDefault(c => c.SourcePinId == pin.Id); return conn?.PubVarName; } @@ -728,10 +391,8 @@ private void ProcessSubGraphs(ReverseConversionContext ctx) var processedTargets = new HashSet(); var processedNodes = new HashSet(); - // Keep processing until no new control flow nodes are discovered while (ctx.PendingControlFlowNodes.Count > 0) { - // Snapshot current pending nodes var pendingNodes = ctx.PendingControlFlowNodes.ToList(); ctx.PendingControlFlowNodes.Clear(); @@ -752,7 +413,6 @@ private void ProcessSubGraphs(ReverseConversionContext ctx) } } - // Update flow control statements with actual block names foreach (var kvp in ctx.ControlFlowMap) { var flow = kvp.Value; @@ -760,12 +420,7 @@ private void ProcessSubGraphs(ReverseConversionContext ctx) { flow.TrueBlockName = assignments.TrueBlockName; flow.FalseBlockName = assignments.FalseBlockName; - - // Regenerate source code - if (flow.ControlType == FlowControlType.Branch) - flow.SourceCode = $"NextBlock = Branch({flow.ConditionExpression}, \"{flow.TrueBlockName}\", \"{flow.FalseBlockName}\");"; - else if (flow.ControlType == FlowControlType.Loop) - flow.SourceCode = $"NextBlock = Loop({flow.ConditionExpression}, \"{flow.TrueBlockName}\", \"{flow.FalseBlockName}\");"; + flow.RegenerateSourceCode(); } } } @@ -773,15 +428,11 @@ private void ProcessSubGraphs(ReverseConversionContext ctx) private void ProcessBranchSubGraphs(BlueprintNode branchNode, ReverseConversionContext ctx, HashSet processedTargets) { - var arms = new[] { ("True", false), ("False", false) }; - var trueBlockName = string.Empty; var falseBlockName = string.Empty; - - // Determine if this branch is inside a loop body (inherit loopback target) var currentLoopback = ctx.CurrentLoopbackTargetId; - foreach (var (pinName, _) in arms) + foreach (var pinName in new[] { "True", "False" }) { var pin = branchNode.OutputPins.FirstOrDefault(p => p.Name == pinName); if (pin == null) continue; @@ -792,15 +443,10 @@ private void ProcessBranchSubGraphs(BlueprintNode branchNode, ReverseConversionC var targetNode = ctx.Blueprint.GetNodeById(conn.TargetNodeId); if (targetNode == null) continue; - var blockName = GenerateBlockName(ctx); - var block = new BlockDefinition - { - Type = BlockType.NamedBlock, - Name = blockName - }; + var blockName = $"Block_{ctx.BlockCounter++}"; + var block = new BlockDefinition { Type = BlockType.NamedBlock, Name = blockName }; - var visited = new HashSet(); - WalkNode(targetNode, block, ctx, visited, loopbackTargetId: currentLoopback); + WalkNode(targetNode, block, ctx, new HashSet(), loopbackTargetId: currentLoopback); ctx.Script.NamedBlocks[blockName] = block; processedTargets.Add(conn.TargetNodeId); @@ -818,7 +464,6 @@ private void ProcessLoopSubGraphs(BlueprintNode loopNode, ReverseConversionConte var loopBodyBlockName = string.Empty; var loopEndBlockName = string.Empty; - // LoopBody arm (loops back to this LoopNode) var loopBodyPin = loopNode.OutputPins.FirstOrDefault(p => p.Name == "LoopBody"); if (loopBodyPin != null) { @@ -828,29 +473,19 @@ private void ProcessLoopSubGraphs(BlueprintNode loopNode, ReverseConversionConte var targetNode = ctx.Blueprint.GetNodeById(conn.TargetNodeId); if (targetNode != null) { - loopBodyBlockName = GenerateBlockName(ctx); - var block = new BlockDefinition - { - Type = BlockType.NamedBlock, - Name = loopBodyBlockName - }; + loopBodyBlockName = $"Block_{ctx.BlockCounter++}"; + var block = new BlockDefinition { Type = BlockType.NamedBlock, Name = loopBodyBlockName }; - // Set loopback context for nested sub-blocks ctx.CurrentLoopbackTargetId = loopNode.Id; - - var visited = new HashSet(); - WalkNode(targetNode, block, ctx, visited, loopbackTargetId: loopNode.Id); + WalkNode(targetNode, block, ctx, new HashSet(), loopbackTargetId: loopNode.Id); ctx.Script.NamedBlocks[loopBodyBlockName] = block; processedTargets.Add(conn.TargetNodeId); - - // Record loop body block for Phase 3 condition duplication ctx.LoopBodyBlocks[loopNode.Id] = block; } } } - // LoopEnd arm var loopEndPin = loopNode.OutputPins.FirstOrDefault(p => p.Name == "LoopEnd"); if (loopEndPin != null) { @@ -860,15 +495,10 @@ private void ProcessLoopSubGraphs(BlueprintNode loopNode, ReverseConversionConte var targetNode = ctx.Blueprint.GetNodeById(conn.TargetNodeId); if (targetNode != null) { - loopEndBlockName = GenerateBlockName(ctx); - var block = new BlockDefinition - { - Type = BlockType.NamedBlock, - Name = loopEndBlockName - }; + loopEndBlockName = $"Block_{ctx.BlockCounter++}"; + var block = new BlockDefinition { Type = BlockType.NamedBlock, Name = loopEndBlockName }; - var visited = new HashSet(); - WalkNode(targetNode, block, ctx, visited, loopbackTargetId: null); + WalkNode(targetNode, block, ctx, new HashSet(), loopbackTargetId: null); ctx.Script.NamedBlocks[loopEndBlockName] = block; processedTargets.Add(conn.TargetNodeId); @@ -880,213 +510,9 @@ private void ProcessLoopSubGraphs(BlueprintNode loopNode, ReverseConversionConte } // ────────────────────────────────────────────── - // Phase 3: Loop Condition Duplication - // ────────────────────────────────────────────── - - private void DuplicateLoopConditions(ReverseConversionContext ctx) - { - foreach (var kvp in ctx.LoopBodyBlocks) - { - var loopNodeId = kvp.Key; - var loopBodyBlock = kvp.Value; - - if (!ctx.LoopNodes.TryGetValue(loopNodeId, out var loopNode)) continue; - - // Get the condition expression for this loop - var flow = ctx.ControlFlowMap.TryGetValue(loopNodeId, out var f) ? f : null; - if (flow == null) continue; - - var conditionExpr = flow.ConditionExpression; - if (string.IsNullOrEmpty(conditionExpr)) continue; - - // Find the statements in the main block that evaluate the condition - // These are the statements between the last non-related statement and the Loop statement - // For simplicity: find the condition evaluation chain (Get + CallHelper statements before the Loop) - var condStmts = FindConditionStatements(loopNode, ctx); - if (condStmts.Count == 0) continue; - - // Insert duplicates before each LoopBodyEnd in the loop body block - var insertions = new List<(int index, List stmts)>(); - - for (int i = 0; i < loopBodyBlock.Statements.Count; i++) - { - var stmt = loopBodyBlock.Statements[i]; - if (stmt is FlowControlStatement flowStmt - && flowStmt.ControlType == FlowControlType.LoopBodyEnd) - { - var dupStmts = condStmts.Select(CloneStatement).ToList(); - insertions.Add((i, dupStmts.Cast().ToList())); - } - } - - // Apply insertions in reverse order to preserve indices - foreach (var (index, stmts) in insertions.OrderByDescending(x => x.index)) - loopBodyBlock.Statements.InsertRange(index, stmts); - } - } - - /// - /// Finds the condition evaluation statements for a Loop node. - /// These are the Get/CallHelper statements that compute the condition value. - /// - private List FindConditionStatements(BlueprintNode loopNode, - ReverseConversionContext ctx) - { - var result = new List(); - - // The loop's condition comes from a data input pin. - // Trace back through the data chain to find all evaluation statements. - var conditionPin = loopNode.InputPins.FirstOrDefault(p => p.Name == "Condition"); - if (conditionPin == null) return result; - - // Find which node feeds the condition - if (!ctx.InputDataMap.TryGetValue((loopNode.Id, "Condition"), out var condInfo)) - return result; - - // The condition is evaluated by some chain of nodes. - // We need to find all statements in the main block that correspond to this chain. - // Strategy: find statements whose PubVar names appear in the condition expression chain. - - var chainPubVars = new HashSet(); - CollectConditionChain(condInfo, ctx, chainPubVars); - - if (chainPubVars.Count == 0) return result; - - // Find matching statements in the main block - if (ctx.Script.MainBlock == null) return result; - - var loopFlowStmt = ctx.ControlFlowMap.TryGetValue(loopNode.Id, out var lf) ? lf : null; - if (loopFlowStmt == null) return result; - - // Collect statements before the Loop that match the condition chain - var loopIndex = ctx.Script.MainBlock.Statements.IndexOf(loopFlowStmt); - if (loopIndex < 0) return result; - - for (int i = loopIndex - 1; i >= 0; i--) - { - var stmt = ctx.Script.MainBlock.Statements[i]; - if (stmt is ExpressionStatement exprStmt) - { - // Check if this statement assigns to one of the chain PubVars - foreach (var pv in chainPubVars) - { - if (exprStmt.SourceCode.StartsWith($"{pv} = ")) - { - result.Insert(0, exprStmt); - break; - } - } - } - } - - return result; - } - - /// - /// Recursively collects all PubVar names in the data chain leading to a condition. - /// - private void CollectConditionChain(DataEdgeInfo info, ReverseConversionContext ctx, - HashSet pubVars) - { - if (!string.IsNullOrEmpty(info.PubVarName)) - pubVars.Add(info.PubVarName); - - // If the source is a CallHelper/Call, trace its argument inputs - if (info.SourceNode.NodeType is BlueprintNodeType.CallHelper or BlueprintNodeType.Call) - { - foreach (var pin in info.SourceNode.InputPins) - { - if (pin.Name == "Exec") continue; - if (ctx.InputDataMap.TryGetValue((info.SourceNode.Id, pin.Name), out var argInfo)) - { - CollectConditionChain(argInfo, ctx, pubVars); - } - } - } - } - - private static ExpressionStatement CloneStatement(ExpressionStatement source) => new() - { - Expression = source.Expression, - SourceCode = source.SourceCode, - LineNumber = source.LineNumber - }; - - // ────────────────────────────────────────────── - // Phase 4: Assemble Source Code - // ────────────────────────────────────────────── - - private void AssembleBlockScript(ReverseConversionContext ctx) - { - var sb = new StringBuilder(); - - // #ConstBlock - var constNodes = ctx.Blueprint.Nodes.OfType().ToList(); - if (constNodes.Count > 0) - { - sb.AppendLine("#ConstBlock"); - foreach (var cn in constNodes) - { - if (string.IsNullOrEmpty(cn.ConstValue)) - { - // Variable without initial value - sb.AppendLine($"{cn.ConstType} {cn.ConstName};"); - } - else - { - var value = cn.ConstType == "string" ? $"\"{cn.ConstValue}\"" : cn.ConstValue; - sb.AppendLine($"const {cn.ConstType} {cn.ConstName} = {value};"); - } - } - sb.AppendLine(); - } - - // #PubVarBlock - if (ctx.AllPubVars.Count > 0) - { - sb.AppendLine("#PubVarBlock"); - foreach (var pv in ctx.AllPubVars) - { - sb.AppendLine($"object {pv};"); - } - sb.AppendLine(); - } - - // #MainBlock - if (ctx.Script.MainBlock != null) - { - sb.AppendLine("#MainBlock"); - foreach (var stmt in ctx.Script.MainBlock.Statements) - { - sb.AppendLine(stmt.SourceCode); - } - sb.AppendLine(); - } - - // Named blocks - foreach (var kvp in ctx.Script.NamedBlocks) - { - sb.AppendLine($"#Block {kvp.Key}"); - foreach (var stmt in kvp.Value.Statements) - { - sb.AppendLine(stmt.SourceCode); - } - sb.AppendLine(); - } - - ctx.Script.SourceCode = sb.ToString().TrimEnd(); - - Log.Debug("[BlueprintToScript] Phase 4 done. Source code length: {Len}", ctx.Script.SourceCode.Length); - } - - // ────────────────────────────────────────────── - // Helpers + // BlockScope helpers // ────────────────────────────────────────────── - /// - /// Detects which block scopes end with a LoopBodyEnd by checking if their - /// last node's Exec output connects back to a Loop node. - /// private void DetectAndInsertLoopBodyEnds(Contract.Workflow.Blueprint bp, ReverseConversionContext ctx) { foreach (var scope in bp.BlockScopes) @@ -1096,7 +522,6 @@ private void DetectAndInsertLoopBodyEnds(Contract.Workflow.Blueprint bp, Reverse var lastNodeId = scope.NodeIds[scope.NodeIds.Count - 1]; if (!ctx.NodeById.TryGetValue(lastNodeId, out var lastNode)) continue; - // Check if last node's Exec output connects to a Loop node var execOutPin = lastNode.OutputPins.FirstOrDefault(p => p.Name == "Exec"); if (execOutPin == null) continue; @@ -1106,29 +531,18 @@ private void DetectAndInsertLoopBodyEnds(Contract.Workflow.Blueprint bp, Reverse if (!ctx.NodeById.TryGetValue(execConn.TargetNodeId, out var targetNode)) continue; if (targetNode.NodeType != BlueprintNodeType.Loop) continue; - // Find the block containing the Loop node var returnToBlock = FindScopeContainingNode(targetNode.Id, bp); if (returnToBlock == null) continue; - // Find the block definition for this scope BlockDefinition? blockDef = scope.IsMainBlock ? ctx.Script.MainBlock : ctx.Script.NamedBlocks.GetValueOrDefault(scope.Name); if (blockDef == null) continue; - blockDef.Statements.Add(new FlowControlStatement - { - ControlType = FlowControlType.LoopBodyEnd, - SourceCode = $"NextBlock = LoopBodyEnd(\"{returnToBlock}\");", - LineNumber = 1, - LoopBodyEndReturnTo = returnToBlock - }); + blockDef.Statements.Add(CreateLoopBodyEndStatement(returnToBlock)); } } - /// - /// Finds the block scope name containing a given node ID. - /// private static string? FindScopeContainingNode(string nodeId, Contract.Workflow.Blueprint bp) { foreach (var scope in bp.BlockScopes) @@ -1138,60 +552,4 @@ private void DetectAndInsertLoopBodyEnds(Contract.Workflow.Blueprint bp, Reverse } return null; } - - private string GeneratePubVarName(ReverseConversionContext ctx) - { - return $"vaaa{ctx.PubVarCounter++:D4}"; - } - - private string GenerateBlockName(ReverseConversionContext ctx) - { - return $"Block_{ctx.BlockCounter++}"; - } - - // ────────────────────────────────────────────── - // Context classes - // ────────────────────────────────────────────── - - private class ReverseConversionContext - { - public required Contract.Workflow.Blueprint Blueprint { get; set; } - public required BlockScript Script { get; set; } - - // Phase 1: Analysis - public Dictionary NodeById { get; set; } = new(); - public List ExecConnections { get; set; } = new(); - public List DataConnections { get; set; } = new(); - public Dictionary<(string nodeId, string pinName), DataEdgeInfo> InputDataMap { get; set; } = new(); - public HashSet<(string nodeId, string pinName)> ConsumedOutputs { get; set; } = new(); - public List AllPubVars { get; set; } = new(); - public List AutoPubVars { get; set; } = new(); - public int PubVarCounter { get; set; } = 1; - public int BlockCounter { get; set; } = 0; - - // Phase 2: Execution walk - public Dictionary ControlFlowMap { get; set; } = new(); - public List PendingControlFlowNodes { get; set; } = new(); - public Dictionary LoopNodes { get; set; } = new(); - public Dictionary LoopBodyBlocks { get; set; } = new(); - public Dictionary BlockNameAssignments { get; set; } = new(); - - // Block scope fast path - public Dictionary ScopesByName { get; set; } = new(); - - /// - /// Current loopback target ID when walking inside a loop body. - /// Set by ProcessLoopSubGraphs, read by ProcessBranchSubGraphs. - /// - public string? CurrentLoopbackTargetId { get; set; } - } - - private class DataEdgeInfo - { - public BlueprintNode SourceNode { get; set; } = null!; - public string SourcePinName { get; set; } = string.Empty; - public BlueprintPin SourcePin { get; set; } = null!; - public string? PubVarName { get; set; } - public BlueprintConnection Connection { get; set; } = null!; - } } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/CallHelperNodeExportStrategy.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/CallHelperNodeExportStrategy.cs index 4b828601..97eed86b 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/CallHelperNodeExportStrategy.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/CallHelperNodeExportStrategy.cs @@ -11,10 +11,11 @@ public class CallHelperNodeExportStrategy : INodeExportStrategy { if (node is not CallHelperNode callHelper) return null; var helperArgs = helper.GetInputArgs(callHelper); + var expression = $"{callHelper.HelperFunctionName}({helperArgs})"; return new ExpressionStatement { - Expression = $"{callHelper.HelperFunctionName}({helperArgs});", - SourceCode = $"{callHelper.HelperFunctionName}({helperArgs});", + Expression = expression, + SourceCode = expression + ";", LineNumber = 1 }; } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/DataEdgeBuilder.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/DataEdgeBuilder.cs index abf27b87..a7220917 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/DataEdgeBuilder.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/DataEdgeBuilder.cs @@ -108,7 +108,7 @@ private void ProcessArgument(string arg, BlueprintNode targetNode, string target } // PubVar reference → find PubVarAssignment source - if (ExprUtils.IsPubVarName(trimmed)) + if (context.PubVarNames.Contains(trimmed)) { ConnectPubVarSource(trimmed, targetNode, targetPinName, context); return; @@ -190,24 +190,6 @@ private void ConnectPubVarSource(string pubVarName, BlueprintNode targetNode, } } - // If not found in PubVarAssignments, check if it's a standalone Get assignment - // (e.g., vaaa0002 = Get("currentLoop")) - foreach (var kvp in context.PubVarAssignments) - { - if (kvp.Value.PubVarName == pubVarName) - { - context.DataEdges.Add(new PendingDataEdge - { - SourceNodeId = kvp.Value.SourceNode.Id, - SourcePinName = kvp.Value.SourcePin.Name, - TargetNodeId = targetNode.Id, - TargetPinName = targetPinName, - PubVarName = pubVarName - }); - return; - } - } - Log.Warning("[DataEdgeBuilder] PubVar source not found: {PubVar}", pubVarName); } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ExprUtils.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ExprUtils.cs index 1d7c05cb..f60c28e6 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ExprUtils.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ExprUtils.cs @@ -48,7 +48,7 @@ public static class ExprUtils } /// Parses a full statement (may be assignment or plain expression). - public static (ExpressionSyntax? rightExpr, string? assignedVar, bool isPubVar)? ParseStatement(string statement) + public static (ExpressionSyntax? rightExpr, string? assignedVar)? ParseStatement(string statement) { try { @@ -57,7 +57,7 @@ public static (ExpressionSyntax? rightExpr, string? assignedVar, bool isPubVar)? var root = syntaxTree.GetCompilationUnitRoot(); var globalStmt = root.Members.FirstOrDefault() as GlobalStatementSyntax; var stmt = globalStmt?.Statement as ExpressionStatementSyntax; - if (stmt?.Expression == null) return (null, null, false); + if (stmt?.Expression == null) return (null, null); if (stmt.Expression is AssignmentExpressionSyntax outerAssignment) { @@ -69,11 +69,11 @@ public static (ExpressionSyntax? rightExpr, string? assignedVar, bool isPubVar)? rightExpr = nested.Right; } var assignedVar = (innermost ?? outerAssignment).Left.ToString().Trim(); - return (rightExpr, assignedVar, IsPubVarName(assignedVar)); + return (rightExpr, assignedVar); } - return (stmt.Expression, null, false); + return (stmt.Expression, null); } - catch { return (null, null, false); } + catch { return (null, null); } } /// Extracts method name from an invocation expression. @@ -85,10 +85,6 @@ public static string GetMethodName(InvocationExpressionSyntax invoke) return string.Empty; } - /// Checks if a name follows the PubVar convention (vaaaNNNN). - public static bool IsPubVarName(string name) - => !string.IsNullOrEmpty(name) && name.StartsWith("vaaa") && name.Length >= 8; - /// Checks if a token is a simple variable reference. public static bool IsVariableReference(string token) { @@ -100,8 +96,35 @@ public static bool IsVariableReference(string token) return Regex.IsMatch(token, @"^[a-zA-Z_]\w*$"); } - /// Generates a PubVar name like vaaa0001. - public static string GeneratePubVarName(int counter) => $"vaaa{counter:D4}"; + /// + /// Generates a PubVar name from a linear counter, cycling from vaaa0001 to vzzz9999. + /// Format: 'v' + 3 lowercase letters + 4 digits. Total capacity: 26^3 * 10000 = 175,760,000. + /// + public static string GeneratePubVarName(int counter) + { + int letterPart = counter / 10000; // 0 = aaa, 1 = aab, ... + int digitPart = counter % 10000; + char c3 = (char)('a' + letterPart % 26); + char c2 = (char)('a' + (letterPart / 26) % 26); + char c1 = (char)('a' + (letterPart / 676) % 26); + return $"v{c1}{c2}{c3}{digitPart:D4}"; + } + + /// + /// Tries to extract the linear counter from an auto-generated PubVar name. + /// Returns null if the name doesn't match the auto-generation format. + /// + public static int? TryExtractPubVarCounter(string name) + { + // Format: v + 3 lowercase letters + 4 digits + if (name == null || name.Length != 8 || name[0] != 'v') + return null; + for (int i = 1; i <= 3; i++) + if (name[i] < 'a' || name[i] > 'z') return null; + if (!int.TryParse(name[4..], out var digitPart)) return null; + int letterPart = (name[1] - 'a') * 676 + (name[2] - 'a') * 26 + (name[3] - 'a'); + return letterPart * 10000 + digitPart; + } /// /// Finds all nested invocation arguments in an invocation (non-builtin only). diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ScriptFormatter.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ScriptFormatter.cs index 1f24c1b1..77266ca0 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ScriptFormatter.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ScriptFormatter.cs @@ -26,12 +26,13 @@ public FormattedBlockScript Format(BlockScript script, PipelineContext context) { var result = new FormattedBlockScript(); - // Initialize counter: find max existing PubVar number to avoid conflicts + // Initialize counter: find max existing PubVar counter to avoid conflicts context.NextPubVarCounter = 1; foreach (var name in context.PubVarNames) { - if (name.StartsWith("vaaa") && name.Length >= 8 && int.TryParse(name[4..], out var num)) - context.NextPubVarCounter = Math.Max(context.NextPubVarCounter, num + 1); + var existingCounter = ExprUtils.TryExtractPubVarCounter(name); + if (existingCounter.HasValue) + context.NextPubVarCounter = Math.Max(context.NextPubVarCounter, existingCounter.Value + 1); } // Format MainBlock @@ -194,7 +195,7 @@ private List FormatExpressionStatement(ExpressionStatement e var parsed = ExprUtils.ParseStatement(expression); if (parsed == null) return result; - var (rightExpr, assignedVar, isPubVar) = parsed.Value; + var (rightExpr, assignedVar) = parsed.Value; if (rightExpr == null) return result; @@ -264,8 +265,8 @@ private List FormatInvocation( var firstArgExpr = invoke.ArgumentList.Arguments[0].Expression; getVarName = ExprUtils.GetStringLiteralValue(firstArgExpr) ?? currentArgExprs[0]; } - // Auto-generate PubVar if not already assigned - if (string.IsNullOrEmpty(assignedVar) || !ExprUtils.IsPubVarName(assignedVar)) + // Auto-generate PubVar if not already assigned per §4.3 (pre-expanded format may already assign PubVars) + if (string.IsNullOrEmpty(assignedVar) || !context.PubVarNames.Contains(assignedVar)) { pubVarTarget = ExprUtils.GeneratePubVarName(context.NextPubVarCounter++); if (!context.PubVarNames.Contains(pubVarTarget)) @@ -281,10 +282,8 @@ private List FormatInvocation( if (!string.IsNullOrEmpty(assignedVar) && assignedVar != "_") { kind = FormattedStatementKind.Assignment; - pubVarTarget = ExprUtils.IsPubVarName(assignedVar) ? assignedVar : null; - // Register as PubVar if it follows the convention - if (ExprUtils.IsPubVarName(assignedVar) && !context.PubVarNames.Contains(assignedVar)) - context.PubVarNames.Add(assignedVar); + // PubVarTarget is set only if the assigned variable is a declared PubVar + pubVarTarget = context.PubVarNames.Contains(assignedVar) ? assignedVar : null; } else { @@ -419,8 +418,10 @@ private List FormatInvocation( var trimmed = conditionExpression.Trim(); - // Already a PubVar reference - if (ExprUtils.IsPubVarName(trimmed)) + // Already a PubVar reference — skip expansion per BlockScript spec §4.3: + // pre-expanded format (where nested calls have already been flattened into PubVar assignments) + // is a valid input format and should not be re-expanded. + if (context.PubVarNames.Contains(trimmed)) return (result, trimmed); // ConstBlock variable diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ReversePipeline/BlockScriptAssembler.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ReversePipeline/BlockScriptAssembler.cs new file mode 100644 index 00000000..33231fac --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ReversePipeline/BlockScriptAssembler.cs @@ -0,0 +1,75 @@ +using System.Linq; +using System.Text; +using KitX.Core.Contract.Workflow; +using Serilog; + +namespace KitX.Core.Workflow.Blueprint.ReversePipeline; + +/// +/// Phase 4 of reverse conversion: assembles the final BlockScript source code +/// from the constructed block definitions. +/// +internal class BlockScriptAssembler +{ + public void Assemble(ReverseConversionContext ctx) + { + var sb = new StringBuilder(); + + // #ConstBlock + var constNodes = ctx.Blueprint.Nodes.OfType().ToList(); + if (constNodes.Count > 0) + { + sb.AppendLine("#ConstBlock"); + foreach (var cn in constNodes) + { + if (string.IsNullOrEmpty(cn.ConstValue)) + { + sb.AppendLine($"{cn.ConstType} {cn.ConstName};"); + } + else + { + var value = cn.ConstType == "string" ? $"\"{cn.ConstValue}\"" : cn.ConstValue; + sb.AppendLine($"const {cn.ConstType} {cn.ConstName} = {value};"); + } + } + sb.AppendLine(); + } + + // #PubVarBlock + if (ctx.AllPubVars.Count > 0) + { + sb.AppendLine("#PubVarBlock"); + foreach (var pv in ctx.AllPubVars) + { + sb.AppendLine($"object {pv};"); + } + sb.AppendLine(); + } + + // #MainBlock + if (ctx.Script.MainBlock != null) + { + sb.AppendLine("#MainBlock"); + foreach (var stmt in ctx.Script.MainBlock.Statements) + { + sb.AppendLine(stmt.SourceCode); + } + sb.AppendLine(); + } + + // Named blocks + foreach (var kvp in ctx.Script.NamedBlocks) + { + sb.AppendLine($"#Block {kvp.Key}"); + foreach (var stmt in kvp.Value.Statements) + { + sb.AppendLine(stmt.SourceCode); + } + sb.AppendLine(); + } + + ctx.Script.SourceCode = sb.ToString().TrimEnd(); + + Log.Debug("[BlueprintToScript] Phase 4 done. Source code length: {Len}", ctx.Script.SourceCode.Length); + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ReversePipeline/BlueprintAnalyzer.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ReversePipeline/BlueprintAnalyzer.cs new file mode 100644 index 00000000..442fec3b --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ReversePipeline/BlueprintAnalyzer.cs @@ -0,0 +1,112 @@ +using System.Collections.Generic; +using System.Linq; +using KitX.Core.Contract.Workflow; +using KitX.Core.Workflow.Blueprint.Pipeline; +using Serilog; + +namespace KitX.Core.Workflow.Blueprint.ReversePipeline; + +/// +/// Phase 1 of reverse conversion: analyzes Blueprint to build data indexes, +/// classify connections, and auto-assign PubVar names where missing. +/// +internal class BlueprintAnalyzer +{ + public void Analyze(ReverseConversionContext ctx) + { + var bp = ctx.Blueprint; + + // Build node lookup + foreach (var node in bp.Nodes) + ctx.NodeById[node.Id] = node; + + // Classify connections and build data indexes + foreach (var conn in bp.Connections) + { + var sourceNode = bp.GetNodeById(conn.SourceNodeId); + if (sourceNode == null) continue; + + var sourcePin = sourceNode.GetPinById(conn.SourcePinId); + if (sourcePin == null) continue; + + if (sourcePin.Type == PinType.Execution) + { + ctx.ExecConnections.Add(conn); + } + else + { + ctx.DataConnections.Add(conn); + + // Build input data map + var targetNode = bp.GetNodeById(conn.TargetNodeId); + if (targetNode != null) + { + var targetPin = targetNode.GetPinById(conn.TargetPinId); + if (targetPin != null) + { + ctx.InputDataMap[(conn.TargetNodeId, targetPin.Name)] = new DataEdgeInfo + { + SourceNode = sourceNode, + SourcePinName = sourcePin.Name, + SourcePin = sourcePin, + PubVarName = conn.PubVarName, + Connection = conn + }; + + // Track consumed outputs + ctx.ConsumedOutputs.Add((conn.SourceNodeId, sourcePin.Name)); + } + } + } + } + + // Assign PubVar names where needed but missing + foreach (var conn in ctx.DataConnections) + { + if (!string.IsNullOrEmpty(conn.PubVarName)) continue; + + var sourceNode = bp.GetNodeById(conn.SourceNodeId); + if (sourceNode == null) continue; + + // ConstNode references don't need PubVar + if (sourceNode.NodeType == BlueprintNodeType.Const) continue; + + // GetNode, CallNode, CallHelperNode outputs need PubVar if consumed downstream + if (sourceNode.NodeType is BlueprintNodeType.Get or BlueprintNodeType.Call + or BlueprintNodeType.CallHelper) + { + var sourcePin = sourceNode.GetPinById(conn.SourcePinId); + if (sourcePin == null) continue; + + var pubVar = ExprUtils.GeneratePubVarName(ctx.PubVarCounter++); + conn.PubVarName = pubVar; + ctx.AutoPubVars.Add(pubVar); + + // Update the InputDataMap entry + var targetNode = bp.GetNodeById(conn.TargetNodeId); + if (targetNode != null) + { + var targetPin = targetNode.GetPinById(conn.TargetPinId); + if (targetPin != null && ctx.InputDataMap.TryGetValue( + (conn.TargetNodeId, targetPin.Name), out var info)) + { + info.PubVarName = pubVar; + } + } + + Log.Debug("[BlueprintToScript] Auto-assigned PubVar {PubVar} for {NodeType}.{Pin}", + pubVar, sourceNode.NodeType, sourcePin.Name); + } + } + + // Collect all PubVar names + foreach (var conn in ctx.DataConnections) + { + if (!string.IsNullOrEmpty(conn.PubVarName) && !ctx.AllPubVars.Contains(conn.PubVarName)) + ctx.AllPubVars.Add(conn.PubVarName); + } + + Log.Debug("[BlueprintToScript] Phase 1 done: {Nodes} nodes, {Exec} exec conns, {Data} data conns, {PubVars} pubvars", + ctx.NodeById.Count, ctx.ExecConnections.Count, ctx.DataConnections.Count, ctx.AllPubVars.Count); + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ReversePipeline/ConditionDuplicator.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ReversePipeline/ConditionDuplicator.cs new file mode 100644 index 00000000..accb3e55 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ReversePipeline/ConditionDuplicator.cs @@ -0,0 +1,126 @@ +using System.Collections.Generic; +using System.Linq; +using KitX.Core.Contract.Workflow; + +namespace KitX.Core.Workflow.Blueprint.ReversePipeline; + +/// +/// Phase 3 of reverse conversion: duplicates Loop condition evaluation statements +/// before each LoopBodyEnd in loop body blocks. +/// +internal class ConditionDuplicator +{ + public void Duplicate(ReverseConversionContext ctx) + { + foreach (var kvp in ctx.LoopBodyBlocks) + { + var loopNodeId = kvp.Key; + var loopBodyBlock = kvp.Value; + + if (!ctx.LoopNodes.TryGetValue(loopNodeId, out var loopNode)) continue; + + var flow = ctx.ControlFlowMap.TryGetValue(loopNodeId, out var f) ? f : null; + if (flow == null) continue; + + var conditionExpr = flow.ConditionExpression; + if (string.IsNullOrEmpty(conditionExpr)) continue; + + var condStmts = FindConditionStatements(loopNode, ctx); + if (condStmts.Count == 0) continue; + + // Insert duplicates before each LoopBodyEnd in the loop body block + var insertions = new List<(int index, List stmts)>(); + + for (int i = 0; i < loopBodyBlock.Statements.Count; i++) + { + var stmt = loopBodyBlock.Statements[i]; + if (stmt is FlowControlStatement flowStmt + && flowStmt.ControlType == FlowControlType.LoopBodyEnd) + { + var dupStmts = condStmts.Select(CloneStatement).ToList(); + insertions.Add((i, dupStmts.Cast().ToList())); + } + } + + // Apply insertions in reverse order to preserve indices + foreach (var (index, stmts) in insertions.OrderByDescending(x => x.index)) + loopBodyBlock.Statements.InsertRange(index, stmts); + } + } + + /// + /// Finds the condition evaluation statements for a Loop node. + /// These are the Get/CallHelper statements that compute the condition value. + /// + private List FindConditionStatements(BlueprintNode loopNode, + ReverseConversionContext ctx) + { + var result = new List(); + + var conditionPin = loopNode.InputPins.FirstOrDefault(p => p.Name == "Condition"); + if (conditionPin == null) return result; + + if (!ctx.InputDataMap.TryGetValue((loopNode.Id, "Condition"), out var condInfo)) + return result; + + var chainPubVars = new HashSet(); + CollectConditionChain(condInfo, ctx, chainPubVars); + + if (chainPubVars.Count == 0) return result; + + if (ctx.Script.MainBlock == null) return result; + + var loopFlowStmt = ctx.ControlFlowMap.TryGetValue(loopNode.Id, out var lf) ? lf : null; + if (loopFlowStmt == null) return result; + + var loopIndex = ctx.Script.MainBlock.Statements.IndexOf(loopFlowStmt); + if (loopIndex < 0) return result; + + for (int i = loopIndex - 1; i >= 0; i--) + { + var stmt = ctx.Script.MainBlock.Statements[i]; + if (stmt is ExpressionStatement exprStmt) + { + foreach (var pv in chainPubVars) + { + if (exprStmt.SourceCode.StartsWith($"{pv} = ")) + { + result.Insert(0, exprStmt); + break; + } + } + } + } + + return result; + } + + /// + /// Recursively collects all PubVar names in the data chain leading to a condition. + /// + private static void CollectConditionChain(DataEdgeInfo info, ReverseConversionContext ctx, + HashSet pubVars) + { + if (!string.IsNullOrEmpty(info.PubVarName)) + pubVars.Add(info.PubVarName); + + if (info.SourceNode.NodeType is BlueprintNodeType.CallHelper or BlueprintNodeType.Call) + { + foreach (var pin in info.SourceNode.InputPins) + { + if (pin.Name == "Exec") continue; + if (ctx.InputDataMap.TryGetValue((info.SourceNode.Id, pin.Name), out var argInfo)) + { + CollectConditionChain(argInfo, ctx, pubVars); + } + } + } + } + + private static ExpressionStatement CloneStatement(ExpressionStatement source) => new() + { + Expression = source.Expression, + SourceCode = source.SourceCode, + LineNumber = source.LineNumber + }; +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ReversePipeline/ReverseConversionContext.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ReversePipeline/ReverseConversionContext.cs new file mode 100644 index 00000000..12cb5d1c --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ReversePipeline/ReverseConversionContext.cs @@ -0,0 +1,53 @@ +using System.Collections.Generic; +using KitX.Core.Contract.Workflow; + +namespace KitX.Core.Workflow.Blueprint.ReversePipeline; + +/// +/// Accumulated state for the reverse conversion (Blueprint → BlockScript). +/// Shared across all reverse pipeline phases. +/// +internal class ReverseConversionContext +{ + public required Contract.Workflow.Blueprint Blueprint { get; set; } + public required BlockScript Script { get; set; } + + // Phase 1: Analysis + public Dictionary NodeById { get; set; } = new(); + public List ExecConnections { get; set; } = new(); + public List DataConnections { get; set; } = new(); + public Dictionary<(string nodeId, string pinName), DataEdgeInfo> InputDataMap { get; set; } = new(); + public HashSet<(string nodeId, string pinName)> ConsumedOutputs { get; set; } = new(); + public List AllPubVars { get; set; } = new(); + public List AutoPubVars { get; set; } = new(); + public int PubVarCounter { get; set; } = 1; + public int BlockCounter { get; set; } = 0; + + // Phase 2: Execution walk + public Dictionary ControlFlowMap { get; set; } = new(); + public List PendingControlFlowNodes { get; set; } = new(); + public Dictionary LoopNodes { get; set; } = new(); + public Dictionary LoopBodyBlocks { get; set; } = new(); + public Dictionary BlockNameAssignments { get; set; } = new(); + + // Block scope fast path + public Dictionary ScopesByName { get; set; } = new(); + + /// + /// Current loopback target ID when walking inside a loop body. + /// Set by ProcessLoopSubGraphs, read by ProcessBranchSubGraphs. + /// + public string? CurrentLoopbackTargetId { get; set; } +} + +/// +/// Information about a data edge for input resolution. +/// +internal class DataEdgeInfo +{ + public BlueprintNode SourceNode { get; set; } = null!; + public string SourcePinName { get; set; } = string.Empty; + public BlueprintPin SourcePin { get; set; } = null!; + public string? PubVarName { get; set; } + public BlueprintConnection Connection { get; set; } = null!; +} diff --git a/KitX Standard b/KitX Standard index 302d65fe..0740abec 160000 --- a/KitX Standard +++ b/KitX Standard @@ -1 +1 @@ -Subproject commit 302d65fe3c446ca62c50607022fb50115d316ee7 +Subproject commit 0740abece8fd6655a2a7155bcba2ddb18031c560 From dbecafac4e62fff74c0375503ee08427a2b2ba75 Mon Sep 17 00:00:00 2001 From: StarInk Date: Thu, 9 Apr 2026 02:26:43 +0200 Subject: [PATCH 034/110] =?UTF-8?q?=F0=9F=92=BE=20Feat(Blueprint):=20?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=BE=AA=E7=8E=AF=E8=8A=82=E7=82=B9=E5=A4=84?= =?UTF-8?q?=E7=90=86=EF=BC=8C=E7=A1=AE=E4=BF=9D=E6=9D=A1=E4=BB=B6=E9=87=8D?= =?UTF-8?q?=E8=AF=84=E8=AF=AD=E5=8F=A5=E7=94=9F=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BlueprintToBlockScriptConverter.cs | 111 ++++++++++++++++-- .../Blueprint/Pipeline/PipelineAssembler.cs | 10 +- .../Blueprint/Pipeline/ScriptFormatter.cs | 31 +++++ .../KitX.Core/Workflow/KcsFileService.cs | 12 +- KitX Clients/KitX Dashboard | 2 +- KitX Standard | 2 +- 6 files changed, 151 insertions(+), 17 deletions(-) diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintToBlockScriptConverter.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintToBlockScriptConverter.cs index 0712b800..0a40cc78 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintToBlockScriptConverter.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintToBlockScriptConverter.cs @@ -518,20 +518,25 @@ private void DetectAndInsertLoopBodyEnds(Contract.Workflow.Blueprint bp, Reverse foreach (var scope in bp.BlockScopes) { if (scope.NodeIds.Count == 0) continue; + // Skip scopes that themselves own a Loop node — they are the Loop's parent, + // not a LoopBodyEnd target. + if (scope.NodeIds.Any(id => ctx.NodeById.TryGetValue(id, out var n) && n.NodeType == BlueprintNodeType.Loop)) + continue; var lastNodeId = scope.NodeIds[scope.NodeIds.Count - 1]; if (!ctx.NodeById.TryGetValue(lastNodeId, out var lastNode)) continue; - var execOutPin = lastNode.OutputPins.FirstOrDefault(p => p.Name == "Exec"); - if (execOutPin == null) continue; + // Follow the exec chain from the last node to find if it reaches a Loop node. + // Shared nodes (reused via PubVar) may sit between the scope's last node + // and the Loop, so we must follow the chain through multiple hops. + var (loopNode, pathNodes) = FollowExecChainToLoop(lastNode, ctx); + if (loopNode == null) continue; - var execConn = ctx.ExecConnections.FirstOrDefault(c => c.SourcePinId == execOutPin.Id); - if (execConn == null) continue; - - if (!ctx.NodeById.TryGetValue(execConn.TargetNodeId, out var targetNode)) continue; - if (targetNode.NodeType != BlueprintNodeType.Loop) continue; + // Don't insert LoopBodyEnd if this scope IS the scope containing the Loop + var loopScopeName = FindScopeContainingNode(loopNode.Id, bp); + if (loopScopeName == scope.Name) continue; - var returnToBlock = FindScopeContainingNode(targetNode.Id, bp); + var returnToBlock = loopScopeName; if (returnToBlock == null) continue; BlockDefinition? blockDef = scope.IsMainBlock @@ -539,10 +544,100 @@ private void DetectAndInsertLoopBodyEnds(Contract.Workflow.Blueprint bp, Reverse : ctx.Script.NamedBlocks.GetValueOrDefault(scope.Name); if (blockDef == null) continue; + // Generate condition re-evaluation statements for the Loop's condition + // by tracing the data chain from Loop.Condition back to source nodes. + GenerateConditionStatements(loopNode, blockDef, ctx); + blockDef.Statements.Add(CreateLoopBodyEndStatement(returnToBlock)); } } + /// + /// Follows the exec output chain from a node until reaching a Loop node. + /// Returns the Loop node (if found) and the intermediate nodes traversed. + /// + private (BlueprintNode? loopNode, List path) FollowExecChainToLoop( + BlueprintNode startNode, ReverseConversionContext ctx) + { + var path = new List(); + var current = startNode; + var visited = new HashSet(); + + while (current != null && !visited.Contains(current.Id)) + { + visited.Add(current.Id); + + if (current.NodeType == BlueprintNodeType.Loop) + return (current, path); + + var execOutPin = current.OutputPins.FirstOrDefault(p => p.Name == "Exec"); + if (execOutPin == null) break; + + var execConn = ctx.ExecConnections.FirstOrDefault(c => c.SourcePinId == execOutPin.Id); + if (execConn == null) break; + + if (!ctx.NodeById.TryGetValue(execConn.TargetNodeId, out var next)) break; + + path.Add(next); + current = next; + } + + return (null, path); + } + + /// + /// Generates condition re-evaluation statements for a Loop node by tracing + /// the data chain from Loop.Condition back through source nodes. + /// Produces statements like: + /// vaaa0001 = Get("currentLoop"); + /// vaaa0002 = HelperFuncCompare("BLE", vaaa0001, loopMax); + /// + private void GenerateConditionStatements(BlueprintNode loopNode, BlockDefinition blockDef, + ReverseConversionContext ctx) + { + var condPin = loopNode.InputPins.FirstOrDefault(p => p.Name == "Condition"); + if (condPin == null) return; + + // Find data connection feeding into Loop.Condition + var condConn = ctx.DataConnections.FirstOrDefault(c => c.TargetPinId == condPin.Id); + if (condConn == null) return; + + // Trace and generate statements in reverse order (deepest source first) + var generated = new HashSet(); + GenerateDataChainStatements(condConn.SourceNodeId, blockDef, ctx, generated); + } + + /// + /// Recursively generates assignment statements for the data source chain. + /// E.g., if HelperFuncCompare feeds into Loop.Condition, and Get feeds into HelperFuncCompare, + /// this generates: Get → HelperFuncCompare statements in order. + /// + private void GenerateDataChainStatements(string sourceNodeId, + BlockDefinition blockDef, ReverseConversionContext ctx, HashSet generated) + { + if (generated.Contains(sourceNodeId)) return; + if (!ctx.NodeById.TryGetValue(sourceNodeId, out var sourceNode)) return; + + // First, recursively generate any upstream data dependencies + foreach (var inputPin in sourceNode.InputPins) + { + if (inputPin.Name == "Exec") continue; + var upConn = ctx.DataConnections.FirstOrDefault(c => c.TargetPinId == inputPin.Id); + if (upConn != null) + GenerateDataChainStatements(upConn.SourceNodeId, blockDef, ctx, generated); + } + + // Skip ConstNode — they are declared in ConstBlock, not inline + if (sourceNode.NodeType == BlueprintNodeType.Const) return; + + generated.Add(sourceNodeId); + + // Generate statement for this node + var stmt = GenerateStatement(sourceNode, ctx); + if (stmt != null) + blockDef.Statements.Add(stmt); + } + private static string? FindScopeContainingNode(string nodeId, Contract.Workflow.Blueprint bp) { foreach (var scope in bp.BlockScopes) diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineAssembler.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineAssembler.cs index 28283b6d..eb96ccba 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineAssembler.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineAssembler.cs @@ -216,13 +216,19 @@ private void BuildBlockScopes(Contract.Workflow.Blueprint bp, PipelineContext ct } } - // Remove merged scopes and add remaining to Blueprint + // Remove merged scopes and add remaining to Blueprint. + // Deduplicate: each node belongs to its FIRST occurrence block only. + // This prevents shared data nodes (e.g., loop condition Compare nodes + // duplicated into LoopBodyEnd blocks) from appearing in multiple scopes. + var assignedNodeIds = new HashSet(); foreach (var kvp in tempScopes) { if (merged.Contains(kvp.Key)) continue; var (scope, nodeIds, _) = kvp.Value; - scope.NodeIds = nodeIds; + // Filter out nodes already assigned to an earlier block + var uniqueNodeIds = nodeIds.Where(id => assignedNodeIds.Add(id)).ToList(); + scope.NodeIds = uniqueNodeIds; bp.BlockScopes.Add(scope); } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ScriptFormatter.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ScriptFormatter.cs index 77266ca0..30e90335 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ScriptFormatter.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ScriptFormatter.cs @@ -365,6 +365,37 @@ private List FormatInvocation( if (ExprUtils.FlowControlFunctions.Contains(funcName)) return (new(), invoke.ToString()); + // Get(varName) → extract as a proper Get statement with GetVarName set. + // This is critical for PubVar reuse detection in NodeBuilder: + // cloned Get statements (from Loop condition duplication) must have + // GetVarName set so the reuse check (PubVarTarget + GetVarName) can + // match them to the original Get node instead of creating duplicates. + if (funcName == "Get") + { + var varName = invoke.ArgumentList.Arguments.Count > 0 + ? ExprUtils.GetStringLiteralValue(invoke.ArgumentList.Arguments[0].Expression) + ?? invoke.ArgumentList.Arguments[0].Expression.ToString().Trim('"') + : ""; + + var getPubVar = ExprUtils.GeneratePubVarName(context.NextPubVarCounter++); + if (!context.PubVarNames.Contains(getPubVar)) + context.PubVarNames.Add(getPubVar); + + return (new List + { + new() + { + BlockName = blockName, + Kind = FormattedStatementKind.Assignment, + FunctionName = "Get", + PubVarTarget = getPubVar, + GetVarName = varName, + Arguments = new List { $"\"{varName}\"" }, + OriginalExpression = $"{getPubVar} = Get(\"{varName}\")", + } + }, getPubVar); + } + // This is a helper/regular function call that needs extraction // First, recursively expand ITS arguments var allStmts = new List(); diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/KcsFileService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/KcsFileService.cs index 48bd1b97..94af15a9 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/KcsFileService.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/KcsFileService.cs @@ -15,6 +15,11 @@ namespace KitX.Core.Workflow; /// public class KcsFileService : IKcsFileService { + private static readonly JsonSerializerOptions _jsonOptions = new() + { + WriteIndented = true + }; + /// /// 加载KCS文件 /// @@ -23,7 +28,7 @@ public class KcsFileService : IKcsFileService try { var json = await File.ReadAllTextAsync(filePath); - var kcs = JsonSerializer.Deserialize(json); + var kcs = JsonSerializer.Deserialize(json, _jsonOptions); return kcs; } catch (Exception ex) @@ -40,10 +45,7 @@ public async System.Threading.Tasks.Task SaveKcsFileAsync(string filePath, KcsFi { try { - var json = JsonSerializer.Serialize(kcs, new JsonSerializerOptions - { - WriteIndented = true - }); + var json = JsonSerializer.Serialize(kcs, _jsonOptions); await File.WriteAllTextAsync(filePath, json); Log.Information("[KcsFileService] KCS file saved: {FilePath}", filePath); diff --git a/KitX Clients/KitX Dashboard b/KitX Clients/KitX Dashboard index 2c2cb3b1..981ce814 160000 --- a/KitX Clients/KitX Dashboard +++ b/KitX Clients/KitX Dashboard @@ -1 +1 @@ -Subproject commit 2c2cb3b1adf1a919c4b62a5a372ba0fa4714c00d +Subproject commit 981ce814069d254733590e77c0a7c874b1958a20 diff --git a/KitX Standard b/KitX Standard index 0740abec..dd7a5549 160000 --- a/KitX Standard +++ b/KitX Standard @@ -1 +1 @@ -Subproject commit 0740abece8fd6655a2a7155bcba2ddb18031c560 +Subproject commit dd7a5549228d06be1466e36fb285d9e4aa1fa97f From fc6b066cb1da4ff505026ba65034e5cd57b2132c Mon Sep 17 00:00:00 2001 From: StarInk Date: Thu, 9 Apr 2026 03:54:45 +0200 Subject: [PATCH 035/110] =?UTF-8?q?=F0=9F=93=9D=20Chore(.gitignore):=20Add?= =?UTF-8?q?=20Claude.md=20to=20gitignore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 8397999b..04b46613 100644 --- a/.gitignore +++ b/.gitignore @@ -365,6 +365,7 @@ MigrationBackup/ # Claude Code .claude/ +Claude.md # Package folder Package/* From 95ca021c6443e626a77dcef9d30b80c3298c290e Mon Sep 17 00:00:00 2001 From: StarInk Date: Thu, 9 Apr 2026 15:38:59 +0200 Subject: [PATCH 036/110] =?UTF-8?q?=F0=9F=92=BE=20Feat(BlockScript):=20Ref?= =?UTF-8?q?actor=20error=20logging=20and=20streamline=20helper=20function?= =?UTF-8?q?=20code=20generation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BlockScripting/BlockScriptExecutor.cs | 42 +-- .../HelperFunctionCodeGenerator.cs | 83 ++++++ .../ReversePipeline/BlockScriptAssembler.cs | 8 +- .../Workflow/WorkflowScriptService.cs | 240 +++++++++--------- KitX Clients/KitX Dashboard | 2 +- KitX Standard | 2 +- 6 files changed, 205 insertions(+), 172 deletions(-) create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/HelperFunctionCodeGenerator.cs diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutor.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutor.cs index ad38004e..dbf43a97 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutor.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutor.cs @@ -544,7 +544,7 @@ private async Task ExecuteExpressionAsync( } catch (Exception ex) { - Log.Warning(ex, "[BlockScriptExecutor] Error evaluating expression: {Expression}", expression); + Log.Error(ex, "[BlockScriptExecutor] Error evaluating expression: {Expression}", expression); throw; } } @@ -565,45 +565,7 @@ private string WrapExpressionAsStatement(string expression) /// private string BuildHelperFunctionsCode(List? helperFunctions) { - if (helperFunctions == null || helperFunctions.Count == 0) - return string.Empty; - - var code = new System.Text.StringBuilder(); - - foreach (var func in helperFunctions) - { - // Generate function signature (without static modifier for CSharpScript) - code.Append(func.ReturnType); - code.Append(" "); - code.Append(func.Name); - code.Append("("); - - // Add parameters - for (int i = 0; i < func.Parameters.Count; i++) - { - if (i > 0) code.Append(", "); - code.Append(func.Parameters[i].Type); - code.Append(" "); - code.Append(func.Parameters[i].Name); - } - - code.AppendLine(")"); - code.AppendLine("{"); - - // Add function body - if (!string.IsNullOrWhiteSpace(func.Code)) - { - foreach (var line in func.Code.Split('\n')) - { - code.AppendLine(" " + line); - } - } - - code.AppendLine("}"); - code.AppendLine(); - } - - return code.ToString(); + return HelperFunctionCodeGenerator.GenerateCode(helperFunctions, useStaticModifier: false); } /// diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/HelperFunctionCodeGenerator.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/HelperFunctionCodeGenerator.cs new file mode 100644 index 00000000..97f57096 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/HelperFunctionCodeGenerator.cs @@ -0,0 +1,83 @@ +using System.Collections.Generic; +using System.Text; +using KitX.Core.Contract.Workflow; + +namespace KitX.Core.Workflow.BlockScripting; + +/// +/// Generates C# source code from HelperFunction definitions. +/// Shared utility used by both WorkflowScriptService (KCS mode) and BlockScriptExecutor. +/// +public static class HelperFunctionCodeGenerator +{ + /// + /// Generates C# method declarations from a list of helper functions. + /// + /// The helper functions to generate code for + /// Whether to add 'static' modifier to method signatures + /// Generated C# source code, or empty string if no functions + public static string GenerateCode(List? helperFunctions, bool useStaticModifier = false) + { + if (helperFunctions == null || helperFunctions.Count == 0) + return string.Empty; + + var code = new StringBuilder(); + + foreach (var func in helperFunctions) + { + // Generate method signature + if (useStaticModifier) + code.Append("static "); + + code.Append(func.ReturnType); + code.Append(" "); + code.Append(func.Name); + code.Append("("); + + // Add parameters + for (int i = 0; i < func.Parameters.Count; i++) + { + if (i > 0) code.Append(", "); + code.Append(func.Parameters[i].Type); + code.Append(" "); + code.Append(func.Parameters[i].Name); + } + + code.AppendLine(")"); + code.AppendLine("{"); + + // Add function body + if (!string.IsNullOrWhiteSpace(func.Code)) + { + foreach (var line in func.Code.Split('\n')) + { + code.AppendLine(" " + line); + } + } + + code.AppendLine("}"); + code.AppendLine(); + } + + return code.ToString(); + } + + /// + /// Generates helper function code and appends the main program code after it. + /// Used by KCS mode where helpers are merged with the main program as static methods. + /// + /// The main program code + /// The helper functions to prepend + /// Combined source code + public static string MergeWithMainProgram(string mainCode, List helperFunctions) + { + var combined = new StringBuilder(); + + combined.Append(GenerateCode(helperFunctions, useStaticModifier: true)); + + combined.AppendLine("// --- Main Program ---"); + combined.AppendLine(mainCode); + + return combined.ToString(); + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ReversePipeline/BlockScriptAssembler.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ReversePipeline/BlockScriptAssembler.cs index 33231fac..b95a4fa0 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ReversePipeline/BlockScriptAssembler.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ReversePipeline/BlockScriptAssembler.cs @@ -15,7 +15,7 @@ public void Assemble(ReverseConversionContext ctx) { var sb = new StringBuilder(); - // #ConstBlock + // #ConstBlock — no 'const' keyword in BlockScript ConstBlock var constNodes = ctx.Blueprint.Nodes.OfType().ToList(); if (constNodes.Count > 0) { @@ -29,19 +29,19 @@ public void Assemble(ReverseConversionContext ctx) else { var value = cn.ConstType == "string" ? $"\"{cn.ConstValue}\"" : cn.ConstValue; - sb.AppendLine($"const {cn.ConstType} {cn.ConstName} = {value};"); + sb.AppendLine($"{cn.ConstType} {cn.ConstName} = {value};"); } } sb.AppendLine(); } - // #PubVarBlock + // #PubVarBlock — use 'dynamic' for late-bound type resolution if (ctx.AllPubVars.Count > 0) { sb.AppendLine("#PubVarBlock"); foreach (var pv in ctx.AllPubVars) { - sb.AppendLine($"object {pv};"); + sb.AppendLine($"dynamic {pv};"); } sb.AppendLine(); } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs index 27f731db..2daf2b5f 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs @@ -279,13 +279,7 @@ public void RemoveWorkflow(string workflowId) sw.Stop(); - return includeTimestamp - ? new StringBuilder() - .AppendLine($"[{begin:yyyy-MM-dd HH:mm:ss}] [I] Workflow script posted.") - .AppendLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [I] Script ended, took {sw.ElapsedMilliseconds} ms.") - .AppendLine(result) - .ToString() - : result; + return FormatExecutionResult(result, begin, sw.ElapsedMilliseconds, includeTimestamp); } else { @@ -299,15 +293,7 @@ public void RemoveWorkflow(string workflowId) Log.Error(ex, $"In {location}: Error executing code: {ex.Message}"); - return includeTimestamp - ? new StringBuilder() - .AppendLine($"[{begin:yyyy-MM-dd HH:mm:ss}] [I] Workflow script posted.") - .AppendLine( - $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [E] Exception caught after {sw.ElapsedMilliseconds} ms, Message: {ex.Message}" - ) - .AppendLine(ex.StackTrace) - .ToString() - : ex.StackTrace; + return FormatExecutionError(ex, begin, sw.ElapsedMilliseconds, includeTimestamp); } } @@ -359,14 +345,7 @@ public void RemoveWorkflow(string workflowId) var scriptOutput = WorkflowOutput.GetAndClear(); var returnValue = result?.ToString(); - if (!string.IsNullOrEmpty(scriptOutput)) - { - return string.IsNullOrEmpty(returnValue) - ? scriptOutput - : $"{scriptOutput}\n{returnValue}"; - } - - return returnValue; + return CombineOutput(scriptOutput, returnValue); } catch (Exception ex) { @@ -435,33 +414,14 @@ private async Task EnsurePluginsReadyAsync( var scriptOutput = WorkflowOutput.GetAndClear(); var returnValue = result?.ToString(); - var combinedOutput = !string.IsNullOrEmpty(scriptOutput) - ? (string.IsNullOrEmpty(returnValue) - ? scriptOutput - : $"{scriptOutput}\n{returnValue}") - : returnValue; + var combinedOutput = CombineOutput(scriptOutput, returnValue); - return includeTimestamp - ? new StringBuilder() - .AppendLine($"[{begin:yyyy-MM-dd HH:mm:ss}] [I] Workflow script posted.") - .AppendLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [I] Script ended, took {sw.ElapsedMilliseconds} ms.") - .AppendLine(combinedOutput) - .ToString() - : combinedOutput; + return FormatExecutionResult(combinedOutput, begin, sw.ElapsedMilliseconds, includeTimestamp); } catch (Exception ex) { sw.Stop(); - - return includeTimestamp - ? new StringBuilder() - .AppendLine($"[{begin:yyyy-MM-dd HH:mm:ss}] [I] Workflow script posted.") - .AppendLine( - $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [E] Exception caught after {sw.ElapsedMilliseconds} ms, Message: {ex.Message}" - ) - .AppendLine(ex.StackTrace) - .ToString() - : ex.StackTrace; + return FormatExecutionError(ex, begin, sw.ElapsedMilliseconds, includeTimestamp); } } @@ -501,6 +461,52 @@ public void UpdateAvailablePlugins(List plugins) Log.Information($"[WorkflowScriptService] Updated available plugins: {_availablePlugins.Count} plugins"); } + #region Execution Result Formatting Helpers + + /// + /// Formats successful execution output with optional timestamp header + /// + private static string? FormatExecutionResult(string? output, DateTime begin, long elapsedMs, bool includeTimestamp) + { + if (!includeTimestamp) + return output; + + return new StringBuilder() + .AppendLine($"[{begin:yyyy-MM-dd HH:mm:ss}] [I] Workflow script posted.") + .AppendLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [I] Script ended, took {elapsedMs} ms.") + .AppendLine(output) + .ToString(); + } + + /// + /// Formats execution error with optional timestamp header + /// + private static string? FormatExecutionError(Exception ex, DateTime begin, long elapsedMs, bool includeTimestamp) + { + if (!includeTimestamp) + return ex.StackTrace; + + return new StringBuilder() + .AppendLine($"[{begin:yyyy-MM-dd HH:mm:ss}] [I] Workflow script posted.") + .AppendLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [E] Exception caught after {elapsedMs} ms, Message: {ex.Message}") + .AppendLine(ex.StackTrace) + .ToString(); + } + + /// + /// Combines WorkflowOutput and script return value into a single output string + /// + private static string? CombineOutput(string? scriptOutput, string? returnValue) + { + return !string.IsNullOrEmpty(scriptOutput) + ? (string.IsNullOrEmpty(returnValue) + ? scriptOutput + : $"{scriptOutput}\n{returnValue}") + : returnValue; + } + + #endregion + #region KCS Script Processing Methods /// @@ -636,49 +642,7 @@ public string ApplyConstantsToCode(string code, List constants /// public string MergeHelperFunctions(string mainCode, List helperFunctions) { - var combined = new StringBuilder(); - - // 添加辅助函数作为完整的 C# 方法 - foreach (var func in helperFunctions) - { - // 生成方法签名 - combined.Append("static "); - combined.Append(func.ReturnType); - combined.Append(" "); - combined.Append(func.Name); - combined.Append("("); - - // 添加参数 - for (int i = 0; i < func.Parameters.Count; i++) - { - if (i > 0) combined.Append(", "); - combined.Append(func.Parameters[i].Type); - combined.Append(" "); - combined.Append(func.Parameters[i].Name); - } - - combined.AppendLine(")"); - combined.AppendLine("{"); - - // 添加函数体代码(用户提供的代码被视为方法体内容) - if (!string.IsNullOrWhiteSpace(func.Code)) - { - // 逐行添加函数体,保持缩进 - foreach (var line in func.Code.Split('\n')) - { - combined.AppendLine(" " + line); - } - } - - combined.AppendLine("}"); - combined.AppendLine(); - } - - // 添加主程序 - combined.AppendLine("// --- Main Program ---"); - combined.AppendLine(mainCode); - - return combined.ToString(); + return BlockScripting.HelperFunctionCodeGenerator.MergeWithMainProgram(mainCode, helperFunctions); } /// @@ -758,7 +722,46 @@ public BlockScriptValidationResult ValidateBlockScript(string sourceCode) } /// - /// 执行块脚本 + /// 从 BlockScript 源码的 #ConstBlock 中解析有初始值的常量 + /// + public List ParseConstantsFromBlockScript(string sourceCode) + { + var result = new List(); + + if (string.IsNullOrWhiteSpace(sourceCode)) + return result; + + try + { + var parseResult = BlockScriptParser.Parse(sourceCode); + + if (!parseResult.IsSuccess || parseResult.Script?.ConstBlock == null) + return result; + + foreach (var variable in parseResult.Script.ConstBlock.Variables) + { + if (variable.DefaultValue != null) + { + result.Add(new VariableConstant + { + Name = variable.Name, + DefaultValue = variable.DefaultValue, + UserValue = variable.DefaultValue, + Type = variable.Type + }); + } + } + } + catch (Exception ex) + { + Log.Warning(ex, "[WorkflowScriptService] Error parsing constants from BlockScript"); + } + + return result; + } + + /// + /// 执行块脚本(从已解析的 BlockScript 对象) /// public Task ExecuteBlockScriptAsync( BlockScript script, @@ -771,51 +774,35 @@ public Task ExecuteBlockScriptAsync( /// /// 从块脚本源代码执行 /// - public async Task ExecuteBlockScriptAsync( + public Task ExecuteBlockScriptAsync( string sourceCode, Dictionary? parameters = null, CancellationToken cancellationToken = default) { - // 1. Parse the block script - var parseResult = BlockScriptParser.Parse(sourceCode); - - if (!parseResult.IsSuccess || parseResult.Script == null) - { - return new BlockScriptExecutionResult - { - IsSuccess = false, - ErrorMessage = parseResult.ErrorMessage ?? "Failed to parse block script" - }; - } - - // 2. Validate - var validationResult = BlockScriptExecutor.Validate(parseResult.Script); - if (!validationResult.IsValid) - { - return new BlockScriptExecutionResult - { - IsSuccess = false, - ErrorMessage = string.Join("; ", validationResult.Errors) - }; - } - - // 3. Execute - return await BlockScriptExecutor.ExecuteAsync(parseResult.Script, parameters, cancellationToken); + return ExecuteBlockScriptCoreAsync(sourceCode, null, parameters, cancellationToken); } /// /// 从块脚本源代码执行(带辅助函数) /// - /// 块脚本源代码 - /// 辅助函数列表 - /// 取消令牌 - /// 执行结果 - public async Task ExecuteBlockScriptAsync( + public Task ExecuteBlockScriptAsync( string sourceCode, List helperFunctions, CancellationToken cancellationToken = default) { - // 1. Parse the block script (using original source with attributes) + return ExecuteBlockScriptCoreAsync(sourceCode, helperFunctions, null, cancellationToken); + } + + /// + /// 核心块脚本执行逻辑:解析 → 验证 → 执行 + /// + private async Task ExecuteBlockScriptCoreAsync( + string sourceCode, + List? helperFunctions, + Dictionary? parameters, + CancellationToken cancellationToken) + { + // 1. Parse var parseResult = BlockScriptParser.Parse(sourceCode); if (!parseResult.IsSuccess || parseResult.Script == null) @@ -838,11 +825,12 @@ public async Task ExecuteBlockScriptAsync( }; } - // Store helper functions for execution (these will be injected into each evaluation) - parseResult.Script.HelperFunctions = helperFunctions ?? new List(); + // 3. Attach helper functions if provided + if (helperFunctions != null) + parseResult.Script.HelperFunctions = helperFunctions; - // 3. Execute - return await BlockScriptExecutor.ExecuteAsync(parseResult.Script, null, cancellationToken); + // 4. Execute + return await BlockScriptExecutor.ExecuteAsync(parseResult.Script, parameters, cancellationToken); } #endregion diff --git a/KitX Clients/KitX Dashboard b/KitX Clients/KitX Dashboard index 981ce814..57b84159 160000 --- a/KitX Clients/KitX Dashboard +++ b/KitX Clients/KitX Dashboard @@ -1 +1 @@ -Subproject commit 981ce814069d254733590e77c0a7c874b1958a20 +Subproject commit 57b841599e6c6434ecb895fc1164026192270d06 diff --git a/KitX Standard b/KitX Standard index dd7a5549..1788c034 160000 --- a/KitX Standard +++ b/KitX Standard @@ -1 +1 @@ -Subproject commit dd7a5549228d06be1466e36fb285d9e4aa1fa97f +Subproject commit 1788c03405428dd6744a7cb1a116f7e98cc2e177 From 3cf03979d8eba16b7f40e2cc53bbf7e759828c7d Mon Sep 17 00:00:00 2001 From: StarInk Date: Thu, 9 Apr 2026 17:05:17 +0200 Subject: [PATCH 037/110] =?UTF-8?q?=F0=9F=A7=A9=20Refactor(Workflow-BluePr?= =?UTF-8?q?int=20Pipeline):=20split=20large=20pipeline=20classes=20into=20?= =?UTF-8?q?focused=20phase=20components?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract ExprUtils.GetMethodName/GetLiteralValue from BlockScriptParser (eliminate duplication) - Split BlockScriptParser (1018 lines) → Parser + StructureRecognizer + SyntaxValidator + StatementExtractor - Split BlueprintToBlockScriptConverter (650 lines) → Converter + ExecutionFlowWalker + NodeExportHelper --- .../BlockScripting/BlockScriptParser.cs | 861 +----------------- .../BlockScripting/BlockStatementExtractor.cs | 364 ++++++++ .../BlockStructureRecognizer.cs | 194 ++++ .../BlockScripting/BlockSyntaxValidator.cs | 194 ++++ .../BlueprintToBlockScriptConverter.cs | 588 +----------- .../Workflow/Blueprint/ExecutionFlowWalker.cs | 483 ++++++++++ .../Workflow/Blueprint/NodeExportHelper.cs | 102 +++ .../Workflow/Blueprint/Pipeline/ExprUtils.cs | 4 + 8 files changed, 1401 insertions(+), 1389 deletions(-) create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockStatementExtractor.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockStructureRecognizer.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockSyntaxValidator.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExecutionFlowWalker.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeExportHelper.cs diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptParser.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptParser.cs index 847c051b..01a2a95c 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptParser.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptParser.cs @@ -1,211 +1,25 @@ -using System; -using System.Collections.Generic; +using System; using System.Linq; -using System.Threading; using System.Threading.Tasks; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.Text; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; using Serilog; namespace KitX.Core.Workflow.BlockScripting; /// -/// Recognizes block structure using source text scanning and Roslyn for C# code validation -/// DSL Syntax: -/// #ConstBlock -/// #PubVarBlock -/// #MainBlock -/// #Block BlockName -/// Next block start = previous block end (no explicit end marker needed) -/// Block markers must be on their own line (excluding whitespace) -/// -internal class BlockStructureRecognizer -{ - private readonly string _sourceCode; - private readonly SourceText _sourceText; - private readonly List _blocks = new(); - - public bool HasErrors { get; private set; } - public string ErrorMessage { get; private set; } = string.Empty; - public int ErrorLine { get; private set; } - - public IReadOnlyList Blocks => _blocks; - - public BlockStructureRecognizer(string sourceCode) - { - _sourceCode = sourceCode; - _sourceText = SourceText.From(sourceCode); - } - - /// - /// Recognizes block structure from source code - /// - public void RecognizeBlocks() - { - if (string.IsNullOrWhiteSpace(_sourceCode)) - { - HasErrors = true; - ErrorMessage = "Source code is empty"; - ErrorLine = 0; - return; - } - - var lines = _sourceCode.Split('\n'); - RecognizedBlock? currentBlock = null; - int currentBlockContentStart = -1; - - for (int i = 0; i < lines.Length; i++) - { - var line = lines[i]; - var trimmed = line.Trim(); - - // Check if this line is a block marker - var blockType = TryParseBlockMarker(trimmed); - if (blockType.HasValue) - { - // Finish previous block - if (currentBlock != null) - { - currentBlock.ContentEnd = GetLineStartPosition(i); - currentBlock.Content = ExtractPureCode(currentBlockContentStart, currentBlock.ContentEnd); - _blocks.Add(currentBlock); - } - - // Extract block name for NamedBlock - var blockName = string.Empty; - if (blockType == BlockType.NamedBlock) - { - blockName = trimmed.Substring("#Block ".Length).Trim(); - } - - // Start new block - currentBlock = new RecognizedBlock - { - BlockType = blockType.Value, - BlockName = blockName, - StartLine = i + 1, - ContentStart = GetLineStartPosition(i + 1), // Next line - ContentEnd = -1 - }; - currentBlockContentStart = currentBlock.ContentStart; - } - } - - // Handle last block - if (currentBlock != null) - { - currentBlock.ContentEnd = _sourceCode.Length; - currentBlock.Content = ExtractPureCode(currentBlockContentStart, currentBlock.ContentEnd); - _blocks.Add(currentBlock); - } - - // Validate: must have MainBlock - if (!_blocks.Any(b => b.BlockType == BlockType.MainBlock)) - { - HasErrors = true; - ErrorMessage = "Script must have a #MainBlock"; - ErrorLine = 0; - } - } - - /// - /// Gets the character position in source code for the start of a line (0-indexed) - /// - private int GetLineStartPosition(int zeroBasedLineIndex) - { - if (zeroBasedLineIndex <= 0) return 0; - if (zeroBasedLineIndex >= _sourceText.Lines.Count) return _sourceCode.Length; - - return _sourceText.Lines[zeroBasedLineIndex].Start; - } - - /// - /// Attempts to parse a block marker line - /// - private BlockType? TryParseBlockMarker(string trimmed) - { - // #ConstBlock - if (trimmed == "#ConstBlock") - return BlockType.ConstBlock; - - // #PubVarBlock - if (trimmed == "#PubVarBlock") - return BlockType.PubVarBlock; - - // #MainBlock - if (trimmed == "#MainBlock") - return BlockType.MainBlock; - - // #Block Name - if (trimmed.StartsWith("#Block ")) - { - var name = trimmed.Substring("#Block ".Length).Trim(); - if (!string.IsNullOrEmpty(name)) - return BlockType.NamedBlock; - } - - return null; - } - - /// - /// Extracts pure C# code from content range, removing block marker lines - /// - private string ExtractPureCode(int start, int end) - { - if (start >= end || start < 0 || end > _sourceCode.Length) - return string.Empty; - - var code = _sourceCode.Substring(start, end - start); - var lines = code.Split('\n'); - var pureLines = new List(); - - foreach (var line in lines) - { - var trimmed = line.Trim(); - - // Skip block marker lines - if (trimmed == "#ConstBlock" || - trimmed == "#PubVarBlock" || - trimmed == "#MainBlock" || - trimmed.StartsWith("#Block ")) - { - continue; - } - - pureLines.Add(line); - } - - var result = string.Join("\n", pureLines).Trim(); - Log.Debug("[BlockStructureRecognizer] Extracted pure code ({Length} chars): {Preview}", - result.Length, result.Length > 100 ? result.Substring(0, 100) + "..." : result); - - return result; - } -} - -/// -/// Represents a recognized block with its type, name, and source position -/// -internal class RecognizedBlock -{ - public BlockType BlockType { get; set; } - public string BlockName { get; set; } = string.Empty; - public int StartLine { get; set; } - public int ContentStart { get; set; } - public int ContentEnd { get; set; } - public string Content { get; set; } = string.Empty; -} - -/// -/// Block script parser implementation using Roslyn CSharp syntax analysis +/// Block script parser implementation. +/// Thin orchestrator that delegates to three phases: +/// Phase 1: BlockStructureRecognizer — DSL block structure scanning +/// Phase 2: BlockSyntaxValidator — Roslyn syntax validation +/// Phase 3: BlockStatementExtractor — Statement model extraction + LoopBlock creation /// public class BlockScriptParser : IBlockScriptParser { + private readonly BlockSyntaxValidator _validator = new(); + private readonly BlockStatementExtractor _extractor = new(); + /// - /// Parses a block-based script from source code using new DSL syntax + /// Parses a block-based script from source code /// public BlockScriptParseResult Parse(string sourceCode) { @@ -223,7 +37,7 @@ public BlockScriptParseResult Parse(string sourceCode) { Log.Debug("[BlockScriptParser] Parse called with {LineCount} lines of code", sourceCode.Split('\n').Length); - // Phase 1: Recognize block structure using custom DSL parser + // Phase 1: Recognize block structure var recognizer = new BlockStructureRecognizer(sourceCode); recognizer.RecognizeBlocks(); @@ -244,13 +58,13 @@ public BlockScriptParseResult Parse(string sourceCode) block.BlockType, block.BlockName, block.StartLine, block.Content.Length); } - // Phase 2: Build BlockScript from recognized blocks + // Phase 2+3: Validate and extract statements for each block var script = new BlockScript(); foreach (var recognized in recognizer.Blocks) { - // Validate and parse the pure C# code for this block - var validationResult = ValidatePureBlockCode(recognized); + // Phase 2: Validate syntax + var validationResult = _validator.Validate(recognized); if (!validationResult.IsValid) { return new BlockScriptParseResult @@ -261,8 +75,8 @@ public BlockScriptParseResult Parse(string sourceCode) }; } - // Create BlockDefinition from recognized block - var blockDef = CreateBlockDefinition(recognized, validationResult); + // Phase 3: Extract statements and create BlockDefinition + var blockDef = _extractor.CreateBlockDefinition(recognized, validationResult); // Add to appropriate slot in script switch (recognized.BlockType) @@ -283,18 +97,14 @@ public BlockScriptParseResult Parse(string sourceCode) script.AllBlocks.Add(blockDef); - // Check if this block contains Loop statements and create LoopBlocks - // This allows LoopBodyEnd to return to the LoopBlock (re-evaluate condition) - // instead of returning to the parent block (which would re-execute the whole block) - // Note: With the simplified design, Loop statements are kept as ExpressionStatements - // in the parent block, but we still need LoopBlocks for LoopBodyEnd's return point + // Create LoopBlocks for blocks containing Loop statements if (blockDef.Type == BlockType.MainBlock || blockDef.Type == BlockType.NamedBlock) { - CreateLoopBlocksForBlock(blockDef, script); + _extractor.CreateLoopBlocksForBlock(blockDef, script); } } - // Phase 3: Link blocks sequentially + // Phase 4: Link blocks sequentially BlockLinker.LinkBlocksSequentially(script); script.SourceCode = sourceCode; @@ -320,577 +130,7 @@ public BlockScriptParseResult Parse(string sourceCode) } /// - /// Validates pure C# code within a block using Roslyn - /// - private BlockValidationResult ValidatePureBlockCode(RecognizedBlock recognized) - { - var result = new BlockValidationResult { IsValid = true }; - - if (string.IsNullOrWhiteSpace(recognized.Content)) - { - return result; // Empty blocks are OK - } - - try - { - // Parse the pure C# code - var syntaxTree = CSharpSyntaxTree.ParseText(recognized.Content); - var root = syntaxTree.GetRoot(); - result.ParsedRoot = root; // Save for reuse by CreateBlockDefinition - var diagnostics = syntaxTree.GetDiagnostics(); - - // Check for syntax errors - foreach (var diag in diagnostics) - { - if (diag.Severity == Microsoft.CodeAnalysis.DiagnosticSeverity.Error) - { - result.IsValid = false; - result.ErrorMessage = $"[{recognized.BlockType}] Syntax error: {diag.GetMessage()}"; - result.ErrorLine = recognized.StartLine + (int)diag.Location.GetLineSpan().StartLinePosition.Line; - return result; - } - } - - // Validate block-type-specific rules - ValidateBlockSpecificRules(recognized, root, result); - - return result; - } - catch (Exception ex) - { - result.IsValid = false; - result.ErrorMessage = $"[{recognized.BlockType}] Parse error: {ex.Message}"; - result.ErrorLine = recognized.StartLine; - return result; - } - } - - /// - /// Validates block-type-specific rules - /// - private void ValidateBlockSpecificRules(RecognizedBlock recognized, SyntaxNode root, BlockValidationResult result) - { - // Get all statements in the block - var statements = root.DescendantNodes() - .Where(n => n is StatementSyntax) - .Cast() - .ToList(); - - foreach (var stmt in statements) - { - switch (recognized.BlockType) - { - case BlockType.ConstBlock: - // ConstBlock: only variable declarations allowed, initializers are OK (const values) - if (stmt is not LocalDeclarationStatementSyntax) - { - result.IsValid = false; - result.ErrorMessage = $"[{recognized.BlockType}] Only variable declarations are allowed. Found: {stmt.Kind()}"; - result.ErrorLine = recognized.StartLine + stmt.GetLineNumber(); - return; - } - break; - - case BlockType.PubVarBlock: - // PubVarBlock: only variable declarations allowed, but NO initializers - if (stmt is not LocalDeclarationStatementSyntax) - { - result.IsValid = false; - result.ErrorMessage = $"[{recognized.BlockType}] Only variable declarations are allowed. Found: {stmt.Kind()}"; - result.ErrorLine = recognized.StartLine + stmt.GetLineNumber(); - return; - } - - // PubVarBlock declarations must NOT have initializers - if (stmt is LocalDeclarationStatementSyntax varDecl) - { - foreach (var variable in varDecl.Declaration.Variables) - { - if (variable.Initializer != null) - { - result.IsValid = false; - result.ErrorMessage = $"[{recognized.BlockType}] PubVarBlock variable declarations cannot have initializers. Variable '{variable.Identifier.Text}' has an initializer."; - result.ErrorLine = recognized.StartLine + stmt.GetLineNumber(); - return; - } - } - } - - // PubVarBlock must not contain assignment operations (check for AssignmentExpressionSyntax in any expression statement) - if (stmt is ExpressionStatementSyntax exprStmt) - { - var hasAssignment = exprStmt.DescendantNodes().Any(n => n is AssignmentExpressionSyntax); - if (hasAssignment) - { - result.IsValid = false; - result.ErrorMessage = $"[{recognized.BlockType}] PubVarBlock cannot contain assignment operations."; - result.ErrorLine = recognized.StartLine + stmt.GetLineNumber(); - return; - } - } - break; - - case BlockType.MainBlock: - case BlockType.NamedBlock: - // MainBlock and NamedBlock: no variable declarations allowed - if (stmt is LocalDeclarationStatementSyntax) - { - result.IsValid = false; - result.ErrorMessage = $"[{recognized.BlockType}] Variable declarations are not allowed. Use ConstBlock or PubVarBlock instead."; - result.ErrorLine = recognized.StartLine + stmt.GetLineNumber(); - return; - } - - // Check for prohibited syntax (if/else/for/while/try/catch) - if (ContainsProhibitedSyntax(stmt)) - { - result.IsValid = false; - result.ErrorMessage = $"[{recognized.BlockType}] Prohibited syntax found: if/else/for/while/try/catch are not allowed"; - result.ErrorLine = recognized.StartLine + stmt.GetLineNumber(); - return; - } - break; - } - } - } - - /// - /// Checks if a statement contains prohibited syntax (if/else/for/while/try/catch) - /// - private bool ContainsProhibitedSyntax(StatementSyntax statement) - { - // Check the statement itself - if (statement is IfStatementSyntax || - statement is ForStatementSyntax || - statement is ForEachStatementSyntax || - statement is WhileStatementSyntax || - statement is DoStatementSyntax || - statement is TryStatementSyntax) - { - return true; - } - - // Recursively check child statements - foreach (var child in statement.DescendantNodes()) - { - if (child is IfStatementSyntax || - child is ForStatementSyntax || - child is ForEachStatementSyntax || - child is WhileStatementSyntax || - child is DoStatementSyntax || - child is TryStatementSyntax) - { - return true; - } - } - - return false; - } - - /// - /// Creates a BlockDefinition from a recognized block and its validation result - /// - private BlockDefinition CreateBlockDefinition(RecognizedBlock recognized, BlockValidationResult validationResult) - { - // For ConstBlock/PubVarBlock/MainBlock, use default names if BlockName is empty - var blockName = recognized.BlockName; - if (string.IsNullOrWhiteSpace(blockName)) - { - blockName = recognized.BlockType switch - { - BlockType.ConstBlock => "ConstBlock", - BlockType.PubVarBlock => "PubVarBlock", - BlockType.MainBlock => "MainBlock", - _ => blockName - }; - } - - var blockDef = new BlockDefinition - { - Type = recognized.BlockType, - Name = blockName, - LineNumber = recognized.StartLine - }; - - if (string.IsNullOrWhiteSpace(recognized.Content)) - { - return blockDef; - } - - // Use the pre-parsed root from validation - var root = validationResult.ParsedRoot; - if (root == null) - { - // Fallback: parse if not available (should not happen in normal flow) - var syntaxTree = CSharpSyntaxTree.ParseText(recognized.Content); - root = syntaxTree.GetRoot(); - } - - // Extract variable declarations for ConstBlock and PubVarBlock - if (recognized.BlockType == BlockType.ConstBlock || recognized.BlockType == BlockType.PubVarBlock) - { - var varDeclarations = root.DescendantNodes() - .OfType(); - - foreach (var varDecl in varDeclarations) - { - foreach (var variable in varDecl.Declaration.Variables) - { - var varDefinition = new Contract.Workflow.VariableDeclaration - { - Name = variable.Identifier.Text, - Type = varDecl.Declaration.Type.ToString(), - InitialValueExpression = variable.Initializer?.Value?.ToString() - }; - - // Pre-evaluate constant values - if ((recognized.BlockType == BlockType.ConstBlock || - recognized.BlockType == BlockType.PubVarBlock) && - variable.Initializer?.Value is LiteralExpressionSyntax literal) - { - varDefinition.DefaultValue = GetLiteralValue(literal); - } - - blockDef.Variables.Add(varDefinition); - } - } - } - - // Extract statements for MainBlock and NamedBlock - if (recognized.BlockType == BlockType.MainBlock || recognized.BlockType == BlockType.NamedBlock) - { - ExtractStatements(root, blockDef, recognized.BlockType); - } - - return blockDef; - } - - /// - /// Extracts statements from syntax root into block definition - /// - private void ExtractStatements(SyntaxNode root, BlockDefinition block, BlockType blockType) - { - // First, let's see the raw content being parsed - var rawContent = root.ToFullString(); - Log.Debug("[BlockScriptParser] ExtractStatements: Block '{BlockName}' raw content ({Len} chars):\n{RawContent}", - block.Name, rawContent.Length, rawContent); - - // Also log all descendant nodes to understand the syntax tree structure - var nodeTypes = root.DescendantNodes().Select(n => n.GetType().Name).Distinct().ToList(); - Log.Debug("[BlockScriptParser] ExtractStatements: Node types in tree: {NodeTypes}", - string.Join(", ", nodeTypes)); - - // Log all ExpressionStatementSyntax nodes found - var exprStatements = root.DescendantNodes().OfType().ToList(); - Log.Debug("[BlockScriptParser] ExtractStatements: Found {Count} ExpressionStatementSyntax nodes:", exprStatements.Count); - foreach (var es in exprStatements) - { - Log.Debug(" - Expression type: {ExprType}, Text: {Text}", es.Expression.GetType().Name, es.Expression.ToString()); - } - - foreach (var node in root.DescendantNodes()) - { - if (node is LocalDeclarationStatementSyntax varDecl) - { - foreach (var variable in varDecl.Declaration.Variables) - { - var varDefinition = new Contract.Workflow.VariableDeclaration - { - Name = variable.Identifier.Text, - Type = varDecl.Declaration.Type.ToString(), - InitialValueExpression = variable.Initializer?.Value?.ToString() - }; - - if (variable.Initializer?.Value is LiteralExpressionSyntax literal) - { - varDefinition.DefaultValue = GetLiteralValue(literal); - } - - block.Variables.Add(varDefinition); - } - } - else if (node is ExpressionStatementSyntax exprStmt) - { - var exprText = exprStmt.Expression.ToString(); - - // Log ALL expression statements for debugging - Log.Debug("[BlockScriptParser] ExtractStatements: Processing ExpressionStatementSyntax: Type={ExprType}, Text={ExprText}", - exprStmt.Expression.GetType().Name, exprText); - - // Check for Branch/Loop/LoopBodyEnd in two patterns: - // 1. Direct call: Loop(...) or Branch(...) - // 2. Assignment: NextBlock = Loop(...) or NextBlock = Branch(...) - if (exprStmt.Expression is InvocationExpressionSyntax invoke) - { - var methodName = GetMethodName(invoke); - - if (methodName == "Branch") - { - // Pass full expression text for assignment case - block.Statements.Add(CreateFlowControlStatement(invoke, FlowControlType.Branch, exprStmt.GetLineNumber(), exprText)); - } - else if (methodName == "Loop") - { - // Pass full expression text for assignment case - block.Statements.Add(CreateFlowControlStatement(invoke, FlowControlType.Loop, exprStmt.GetLineNumber(), exprText)); - } - else if (methodName == "LoopBodyEnd") - { - // Pass full expression text for assignment case - block.Statements.Add(CreateFlowControlStatement(invoke, FlowControlType.LoopBodyEnd, exprStmt.GetLineNumber(), exprText)); - } - else - { - // Regular expression statement - block.Statements.Add(new ExpressionStatement - { - LineNumber = exprStmt.GetLineNumber(), - SourceCode = exprText, - Expression = exprText - }); - } - } - else if (exprStmt.Expression is AssignmentExpressionSyntax assignment) - { - // Handle NextBlock = Loop(...) or NextBlock = Branch(...) patterns - Log.Debug("[BlockScriptParser] Processing AssignmentExpressionSyntax: {ExprText}", exprText); - if (assignment.Right is InvocationExpressionSyntax assignInvoke) - { - var methodName = GetMethodName(assignInvoke); - Log.Debug("[BlockScriptParser] assignment.Right is InvocationExpressionSyntax, methodName = {MethodName}", methodName); - - if (methodName == "Branch") - { - // Pass full expression text so executor runs: NextBlock = Branch(...) - block.Statements.Add(CreateFlowControlStatement(assignInvoke, FlowControlType.Branch, exprStmt.GetLineNumber(), exprText)); - } - else if (methodName == "Loop") - { - // Pass full expression text so executor runs: NextBlock = Loop(...) - block.Statements.Add(CreateFlowControlStatement(assignInvoke, FlowControlType.Loop, exprStmt.GetLineNumber(), exprText)); - } - else if (methodName == "LoopBodyEnd") - { - // Pass full expression text so executor runs: NextBlock = LoopBodyEnd(...) - block.Statements.Add(CreateFlowControlStatement(assignInvoke, FlowControlType.LoopBodyEnd, exprStmt.GetLineNumber(), exprText)); - } - else - { - Log.Debug("[BlockScriptParser] Unknown methodName '{MethodName}', treating as ExpressionStatement", methodName); - // Regular expression statement - block.Statements.Add(new ExpressionStatement - { - LineNumber = exprStmt.GetLineNumber(), - SourceCode = exprText, - Expression = exprText - }); - } - } - else - { - Log.Debug("[BlockScriptParser] assignment.Right is NOT InvocationExpressionSyntax, type = {Type}", assignment.Right.GetType().Name); - // Regular expression statement - block.Statements.Add(new ExpressionStatement - { - LineNumber = exprStmt.GetLineNumber(), - SourceCode = exprText, - Expression = exprText - }); - } - } - else - { - // Log what type this expression actually is - Log.Debug("[BlockScriptParser] Unhandled expression type in {BlockType}: {Type} = {Expr}", - blockType, exprStmt.Expression.GetType().Name, exprText); - block.Statements.Add(new ExpressionStatement - { - LineNumber = exprStmt.GetLineNumber(), - SourceCode = exprText, - Expression = exprText - }); - } - } - else if (node is ReturnStatementSyntax returnStmt) - { - block.Statements.Add(new FlowControlStatement - { - LineNumber = returnStmt.GetLineNumber(), - SourceCode = returnStmt.ToFullString(), - ControlType = FlowControlType.Return, - ConditionExpression = returnStmt.Expression?.ToString() ?? string.Empty - }); - } - } - } - - private string GetMethodName(InvocationExpressionSyntax invoke) - { - if (invoke.Expression is IdentifierNameSyntax identifier) - return identifier.Identifier.Text; - if (invoke.Expression is MemberAccessExpressionSyntax member) - return member.Name.Identifier.Text; - return string.Empty; - } - - private FlowControlStatement CreateFlowControlStatement(InvocationExpressionSyntax invoke, FlowControlType type, int baseLine, string? fullExpressionText = null) - { - var args = invoke.ArgumentList.Arguments; - var statement = new FlowControlStatement - { - LineNumber = baseLine, - // Use full expression text if provided (for NextBlock = Loop(...) case) - // Otherwise use just the invocation (for direct Loop() call case) - SourceCode = fullExpressionText ?? invoke.ToFullString(), - ControlType = type - }; - - Log.Debug("[BlockScriptParser] CreateFlowControlStatement: type={Type}, SourceCode={SourceCode}", - type, statement.SourceCode); - - switch (type) - { - case FlowControlType.Branch: - case FlowControlType.Loop: - if (args.Count >= 1) - { - statement.ConditionExpression = args[0].Expression.ToString(); - Log.Debug("[BlockScriptParser] args[0] (condition): {Expr}", args[0].Expression.ToString()); - } - if (args.Count >= 2) - { - statement.TrueBlockName = GetStringLiteral(args[1].Expression); - Log.Debug("[BlockScriptParser] args[1] (trueBlock): raw={Raw}, extracted={Extracted}", - args[1].Expression.ToString(), statement.TrueBlockName); - } - if (args.Count >= 3) - { - statement.FalseBlockName = GetStringLiteral(args[2].Expression); - Log.Debug("[BlockScriptParser] args[2] (falseBlock): raw={Raw}, extracted={Extracted}", - args[2].Expression.ToString(), statement.FalseBlockName); - } - Log.Debug("[BlockScriptParser] Loop/Branch created: Condition={Condition}, TrueBlock={TrueBlock}, FalseBlock={FalseBlock}", - statement.ConditionExpression, statement.TrueBlockName, statement.FalseBlockName); - break; - - case FlowControlType.LoopBodyEnd: - if (args.Count >= 1) - statement.LoopBodyEndReturnTo = GetStringLiteral(args[0].Expression); - break; - } - - return statement; - } - - /// - /// Creates LoopBlocks for blocks containing Loop statements. - /// LoopBlock is used as a "re-entry point" for loop condition re-evaluation. - /// - /// Problem: When LoopBodyEnd("MainBlock") returns to MainBlock, MainBlock re-executes - /// all its statements (including those before the Loop statement). - /// - /// Solution: Create a LoopBlock for each block containing a Loop statement. - /// - Parent block's NextBlockName is set to the LoopBlock (because Loop terminates the block) - /// - LoopBlock contains only the Loop statement (as a FlowControlStatement) - /// - Loop statements are removed from parent block (they act as block terminators) - /// - When LoopBodyEnd returns to the parent block, execution goes to LoopBlock instead - /// - LoopBlock re-executes the Loop statement to re-evaluate the condition - /// - private void CreateLoopBlocksForBlock(BlockDefinition block, BlockScript script) - { - // Find all Loop statements in this block - var loopStatements = block.Statements - .OfType() - .Where(fs => fs.ControlType == FlowControlType.Loop) - .ToList(); - - if (loopStatements.Count == 0) return; - - // Create a LoopBlock for the FIRST Loop statement - // Note: The LoopBlock represents the "re-entry point" for loop condition re-evaluation - var firstLoop = loopStatements[0]; - var loopBlockName = $"{block.Name}_Loop"; - - // Create the LoopBlock - var loopBlock = new BlockDefinition - { - Type = BlockType.LoopBlock, - Name = loopBlockName, - ParentBlockName = block.Name, - LineNumber = firstLoop.LineNumber - }; - - // The LoopBlock's NextBlockName should be the Loop's falseBlock (loop exit) - // This is where execution goes after the loop finishes - loopBlock.NextBlockName = firstLoop.FalseBlockName; - - // Add a copy of the Loop statement to the LoopBlock - // IMPORTANT: Set LoopBodyEndReturnTo to the parent block name so that - // LoopBodyEnd statements can find this Loop node when they return - var loopBlockStatement = new FlowControlStatement - { - LineNumber = firstLoop.LineNumber, - SourceCode = firstLoop.SourceCode, - ControlType = FlowControlType.Loop, - ConditionExpression = firstLoop.ConditionExpression, - TrueBlockName = firstLoop.TrueBlockName, - FalseBlockName = firstLoop.FalseBlockName, - LoopBodyEndReturnTo = block.Name // Parent block name - for LoopBodyEnd lookup - }; - loopBlock.Statements.Add(loopBlockStatement); - - // Add LoopBlock to script - script.NamedBlocks[loopBlockName] = loopBlock; - script.AllBlocks.Add(loopBlock); - script.LoopBlocks[block.Name] = loopBlock; // Key is parent block name, value is LoopBlock - - // CRITICAL: Set parent block's NextBlockName to LoopBlock! - // Because Loop acts as a block terminator, the parent's "next block" is the LoopBlock. - // The BlockLinker will NOT override this because it checks !string.IsNullOrEmpty(). - block.NextBlockName = loopBlockName; - - // Remove Loop statements from parent block since they act as terminators - foreach (var loopStmt in loopStatements) - { - block.Statements.Remove(loopStmt); - } - - Log.Debug("[BlockScriptParser] Created LoopBlock '{LoopBlockName}' for parent '{ParentName}', " + - "parent NextBlock -> '{LoopBlockName}', Loop jumps to {TrueBlock}/{FalseBlock}", - loopBlockName, block.Name, loopBlockName, - firstLoop.TrueBlockName, firstLoop.FalseBlockName); - } - - private string GetStringLiteral(ExpressionSyntax expr) - { - if (expr is LiteralExpressionSyntax literal) - return literal.Token.ValueText; - return expr.ToString().Trim('"'); - } - - private object? GetLiteralValue(LiteralExpressionSyntax literal) - { - return literal.Token.Value; - } - - private class BlockValidationResult - { - public bool IsValid { get; set; } = true; - public string ErrorMessage { get; set; } = string.Empty; - public int ErrorLine { get; set; } - public SyntaxNode? ParsedRoot { get; set; } - } - - /// - /// Parses a block-based script from source code asynchronously - /// - public Task ParseAsync(string sourceCode) - { - return Task.FromResult(Parse(sourceCode)); - } - - /// - /// Validates block script syntax and structure using the new DSL recognizer + /// Validates block script syntax and structure /// public BlockScriptValidationResult Validate(string sourceCode) { @@ -905,11 +145,10 @@ public BlockScriptValidationResult Validate(string sourceCode) try { - // Log the full source code for debugging var codePreview = sourceCode.Length > 2000 ? sourceCode.Substring(0, 2000) + "..." : sourceCode; - Log.Information("[BlockScriptParser] Validating block script with new DSL syntax. Full source code:\n{Code}", codePreview); + Log.Information("[BlockScriptParser] Validating block script. Full source code:\n{Code}", codePreview); - // Use the new BlockStructureRecognizer for validation + // Phase 1: Recognize block structure var recognizer = new BlockStructureRecognizer(sourceCode); recognizer.RecognizeBlocks(); @@ -920,7 +159,6 @@ public BlockScriptValidationResult Validate(string sourceCode) return result; } - // Log recognized blocks Log.Debug("[BlockScriptParser] Validate: Recognized {Count} blocks:", recognizer.Blocks.Count); foreach (var block in recognizer.Blocks) { @@ -935,14 +173,13 @@ public BlockScriptValidationResult Validate(string sourceCode) return result; } - // Validate each block's pure C# code + // Phase 2: Validate each block's pure C# code foreach (var recognized in recognizer.Blocks) { if (string.IsNullOrWhiteSpace(recognized.Content)) continue; - // Validate the pure C# code using Roslyn - var validationResult = ValidatePureBlockCode(recognized); + var validationResult = _validator.Validate(recognized); if (!validationResult.IsValid) { result.IsValid = false; @@ -961,6 +198,14 @@ public BlockScriptValidationResult Validate(string sourceCode) return result; } } + + /// + /// Parses a block-based script from source code asynchronously + /// + public Task ParseAsync(string sourceCode) + { + return Task.FromResult(Parse(sourceCode)); + } } /// @@ -979,40 +224,24 @@ public static void LinkBlocksSequentially(BlockScript script) // Skip if already has explicit control flow target if (!string.IsNullOrEmpty(currentBlock.NextBlockName)) + continue; + + // Skip LoopBlocks (they have their own flow control) + if (currentBlock.Type == BlockType.LoopBlock) + continue; + + // Don't link across block scope boundaries that have flow control + // A block ending with Branch/Loop should NOT fall through + var lastStatement = currentBlock.Statements.LastOrDefault(); + if (lastStatement is FlowControlStatement flowStmt && + (flowStmt.ControlType == FlowControlType.Branch || + flowStmt.ControlType == FlowControlType.Loop || + flowStmt.ControlType == FlowControlType.Return)) { - Log.Debug("[BlockLinker] Block '{BlockName}' already has NextBlockName={NextBlock}, skipping", - currentBlock.Name, currentBlock.NextBlockName); continue; } - // Set sequential fallthrough currentBlock.NextBlockName = nextBlock.Name; - Log.Debug("[BlockLinker] Linked '{FromBlock}' -> '{ToBlock}' (sequential)", - currentBlock.Name, nextBlock.Name); } - - // Log final block (should have empty NextBlockName to signal end) - var lastBlock = blocks.LastOrDefault(); - if (lastBlock != null) - { - Log.Debug("[BlockLinker] Last block is '{BlockName}', NextBlockName={NextBlock} (should be empty for script end)", - lastBlock.Name, lastBlock.NextBlockName ?? "(null)"); - } - } -} - -/// -/// Shared Roslyn syntax extensions for workflow parsing -/// -internal static class RoslynExtensions -{ - /// - /// Gets the 1-based line number for a syntax node - /// - public static int GetLineNumber(this SyntaxNode node) - { - var location = node.GetLocation(); - var lineSpan = location.GetLineSpan(); - return lineSpan.StartLinePosition.Line + 1; } } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockStatementExtractor.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockStatementExtractor.cs new file mode 100644 index 00000000..48ca49b9 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockStatementExtractor.cs @@ -0,0 +1,364 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using KitX.Core.Contract.Workflow; +using KitX.Core.Workflow.Blueprint.Pipeline; +using Serilog; + +namespace KitX.Core.Workflow.BlockScripting; + +/// +/// Extracts statements and variable declarations from validated syntax trees. +/// Phase 3 of BlockScriptParser: builds BlockDefinition contents from Roslyn syntax. +/// +internal class BlockStatementExtractor +{ + /// + /// Creates a BlockDefinition from a recognized block and its validation result + /// + public BlockDefinition CreateBlockDefinition(RecognizedBlock recognized, BlockValidationResult validationResult) + { + // For ConstBlock/PubVarBlock/MainBlock, use default names if BlockName is empty + var blockName = recognized.BlockName; + if (string.IsNullOrWhiteSpace(blockName)) + { + blockName = recognized.BlockType switch + { + BlockType.ConstBlock => "ConstBlock", + BlockType.PubVarBlock => "PubVarBlock", + BlockType.MainBlock => "MainBlock", + _ => blockName + }; + } + + var blockDef = new BlockDefinition + { + Type = recognized.BlockType, + Name = blockName, + LineNumber = recognized.StartLine + }; + + if (string.IsNullOrWhiteSpace(recognized.Content)) + { + return blockDef; + } + + // Use the pre-parsed root from validation + var root = validationResult.ParsedRoot; + if (root == null) + { + // Fallback: parse if not available (should not happen in normal flow) + var syntaxTree = CSharpSyntaxTree.ParseText(recognized.Content); + root = syntaxTree.GetRoot(); + } + + // Extract variable declarations for ConstBlock and PubVarBlock + if (recognized.BlockType == BlockType.ConstBlock || recognized.BlockType == BlockType.PubVarBlock) + { + var varDeclarations = root.DescendantNodes() + .OfType(); + + foreach (var varDecl in varDeclarations) + { + foreach (var variable in varDecl.Declaration.Variables) + { + var varDefinition = new VariableDeclaration + { + Name = variable.Identifier.Text, + Type = varDecl.Declaration.Type.ToString(), + InitialValueExpression = variable.Initializer?.Value?.ToString() + }; + + // Pre-evaluate constant values + if (variable.Initializer?.Value is LiteralExpressionSyntax literal) + { + varDefinition.DefaultValue = ExprUtils.GetLiteralValue(literal); + } + + blockDef.Variables.Add(varDefinition); + } + } + } + + // Extract statements for MainBlock and NamedBlock + if (recognized.BlockType == BlockType.MainBlock || recognized.BlockType == BlockType.NamedBlock) + { + ExtractStatements(root, blockDef, recognized.BlockType); + } + + return blockDef; + } + + /// + /// Creates LoopBlocks for blocks containing Loop statements. + /// LoopBlock is used as a "re-entry point" for loop condition re-evaluation. + /// + public void CreateLoopBlocksForBlock(BlockDefinition block, BlockScript script) + { + // Find all Loop statements in this block + var loopStatements = block.Statements + .OfType() + .Where(fs => fs.ControlType == FlowControlType.Loop) + .ToList(); + + if (loopStatements.Count == 0) return; + + // Create a LoopBlock for the FIRST Loop statement + var firstLoop = loopStatements[0]; + var loopBlockName = $"{block.Name}_Loop"; + + // Create the LoopBlock + var loopBlock = new BlockDefinition + { + Type = BlockType.LoopBlock, + Name = loopBlockName, + ParentBlockName = block.Name, + LineNumber = firstLoop.LineNumber + }; + + loopBlock.NextBlockName = firstLoop.FalseBlockName; + + // Add a copy of the Loop statement to the LoopBlock + var loopBlockStatement = new FlowControlStatement + { + LineNumber = firstLoop.LineNumber, + SourceCode = firstLoop.SourceCode, + ControlType = FlowControlType.Loop, + ConditionExpression = firstLoop.ConditionExpression, + TrueBlockName = firstLoop.TrueBlockName, + FalseBlockName = firstLoop.FalseBlockName, + LoopBodyEndReturnTo = block.Name + }; + loopBlock.Statements.Add(loopBlockStatement); + + script.NamedBlocks[loopBlockName] = loopBlock; + script.AllBlocks.Add(loopBlock); + script.LoopBlocks[block.Name] = loopBlock; + + block.NextBlockName = loopBlockName; + + // Remove Loop statements from parent block since they act as terminators + foreach (var loopStmt in loopStatements) + { + block.Statements.Remove(loopStmt); + } + + Log.Debug("[BlockStatementExtractor] Created LoopBlock '{LoopBlockName}' for parent '{ParentName}', " + + "parent NextBlock -> '{LoopBlockName}', Loop jumps to {TrueBlock}/{FalseBlock}", + loopBlockName, block.Name, loopBlockName, + firstLoop.TrueBlockName, firstLoop.FalseBlockName); + } + + /// + /// Extracts statements from syntax root into block definition + /// + private void ExtractStatements(SyntaxNode root, BlockDefinition block, BlockType blockType) + { + var rawContent = root.ToFullString(); + Log.Debug("[BlockStatementExtractor] ExtractStatements: Block '{BlockName}' raw content ({Len} chars):\n{RawContent}", + block.Name, rawContent.Length, rawContent); + + var nodeTypes = root.DescendantNodes().Select(n => n.GetType().Name).Distinct().ToList(); + Log.Debug("[BlockStatementExtractor] ExtractStatements: Node types in tree: {NodeTypes}", + string.Join(", ", nodeTypes)); + + var exprStatements = root.DescendantNodes().OfType().ToList(); + Log.Debug("[BlockStatementExtractor] ExtractStatements: Found {Count} ExpressionStatementSyntax nodes:", exprStatements.Count); + foreach (var es in exprStatements) + { + Log.Debug(" - Expression type: {ExprType}, Text: {Text}", es.Expression.GetType().Name, es.Expression.ToString()); + } + + foreach (var node in root.DescendantNodes()) + { + if (node is LocalDeclarationStatementSyntax varDecl) + { + foreach (var variable in varDecl.Declaration.Variables) + { + var varDefinition = new VariableDeclaration + { + Name = variable.Identifier.Text, + Type = varDecl.Declaration.Type.ToString(), + InitialValueExpression = variable.Initializer?.Value?.ToString() + }; + + if (variable.Initializer?.Value is LiteralExpressionSyntax literal) + { + varDefinition.DefaultValue = ExprUtils.GetLiteralValue(literal); + } + + block.Variables.Add(varDefinition); + } + } + else if (node is ExpressionStatementSyntax exprStmt) + { + var exprText = exprStmt.Expression.ToString(); + + Log.Debug("[BlockStatementExtractor] Processing ExpressionStatementSyntax: Type={ExprType}, Text={ExprText}", + exprStmt.Expression.GetType().Name, exprText); + + if (exprStmt.Expression is InvocationExpressionSyntax invoke) + { + var methodName = ExprUtils.GetMethodName(invoke); + + if (methodName == "Branch") + { + block.Statements.Add(CreateFlowControlStatement(invoke, FlowControlType.Branch, exprStmt.GetLineNumber(), exprText)); + } + else if (methodName == "Loop") + { + block.Statements.Add(CreateFlowControlStatement(invoke, FlowControlType.Loop, exprStmt.GetLineNumber(), exprText)); + } + else if (methodName == "LoopBodyEnd") + { + block.Statements.Add(CreateFlowControlStatement(invoke, FlowControlType.LoopBodyEnd, exprStmt.GetLineNumber(), exprText)); + } + else + { + block.Statements.Add(new ExpressionStatement + { + LineNumber = exprStmt.GetLineNumber(), + SourceCode = exprText, + Expression = exprText + }); + } + } + else if (exprStmt.Expression is AssignmentExpressionSyntax assignment) + { + Log.Debug("[BlockStatementExtractor] Processing AssignmentExpressionSyntax: {ExprText}", exprText); + if (assignment.Right is InvocationExpressionSyntax assignInvoke) + { + var methodName = ExprUtils.GetMethodName(assignInvoke); + Log.Debug("[BlockStatementExtractor] assignment.Right is InvocationExpressionSyntax, methodName = {MethodName}", methodName); + + if (methodName == "Branch") + { + block.Statements.Add(CreateFlowControlStatement(assignInvoke, FlowControlType.Branch, exprStmt.GetLineNumber(), exprText)); + } + else if (methodName == "Loop") + { + block.Statements.Add(CreateFlowControlStatement(assignInvoke, FlowControlType.Loop, exprStmt.GetLineNumber(), exprText)); + } + else if (methodName == "LoopBodyEnd") + { + block.Statements.Add(CreateFlowControlStatement(assignInvoke, FlowControlType.LoopBodyEnd, exprStmt.GetLineNumber(), exprText)); + } + else + { + Log.Debug("[BlockStatementExtractor] Unknown methodName '{MethodName}', treating as ExpressionStatement", methodName); + block.Statements.Add(new ExpressionStatement + { + LineNumber = exprStmt.GetLineNumber(), + SourceCode = exprText, + Expression = exprText + }); + } + } + else + { + Log.Debug("[BlockStatementExtractor] assignment.Right is NOT InvocationExpressionSyntax, type = {Type}", assignment.Right.GetType().Name); + block.Statements.Add(new ExpressionStatement + { + LineNumber = exprStmt.GetLineNumber(), + SourceCode = exprText, + Expression = exprText + }); + } + } + else + { + Log.Debug("[BlockStatementExtractor] Unhandled expression type in {BlockType}: {Type} = {Expr}", + blockType, exprStmt.Expression.GetType().Name, exprText); + block.Statements.Add(new ExpressionStatement + { + LineNumber = exprStmt.GetLineNumber(), + SourceCode = exprText, + Expression = exprText + }); + } + } + else if (node is ReturnStatementSyntax returnStmt) + { + block.Statements.Add(new FlowControlStatement + { + LineNumber = returnStmt.GetLineNumber(), + SourceCode = returnStmt.ToFullString(), + ControlType = FlowControlType.Return, + ConditionExpression = returnStmt.Expression?.ToString() ?? string.Empty + }); + } + } + } + + private FlowControlStatement CreateFlowControlStatement(InvocationExpressionSyntax invoke, FlowControlType type, int baseLine, string? fullExpressionText = null) + { + var args = invoke.ArgumentList.Arguments; + var statement = new FlowControlStatement + { + LineNumber = baseLine, + SourceCode = fullExpressionText ?? invoke.ToFullString(), + ControlType = type + }; + + Log.Debug("[BlockStatementExtractor] CreateFlowControlStatement: type={Type}, SourceCode={SourceCode}", + type, statement.SourceCode); + + switch (type) + { + case FlowControlType.Branch: + case FlowControlType.Loop: + if (args.Count >= 1) + { + statement.ConditionExpression = args[0].Expression.ToString(); + Log.Debug("[BlockStatementExtractor] args[0] (condition): {Expr}", args[0].Expression.ToString()); + } + if (args.Count >= 2) + { + statement.TrueBlockName = GetStringLiteral(args[1].Expression); + Log.Debug("[BlockStatementExtractor] args[1] (trueBlock): raw={Raw}, extracted={Extracted}", + args[1].Expression.ToString(), statement.TrueBlockName); + } + if (args.Count >= 3) + { + statement.FalseBlockName = GetStringLiteral(args[2].Expression); + Log.Debug("[BlockStatementExtractor] args[2] (falseBlock): raw={Raw}, extracted={Extracted}", + args[2].Expression.ToString(), statement.FalseBlockName); + } + Log.Debug("[BlockStatementExtractor] Loop/Branch created: Condition={Condition}, TrueBlock={TrueBlock}, FalseBlock={FalseBlock}", + statement.ConditionExpression, statement.TrueBlockName, statement.FalseBlockName); + break; + + case FlowControlType.LoopBodyEnd: + if (args.Count >= 1) + statement.LoopBodyEndReturnTo = GetStringLiteral(args[0].Expression); + break; + } + + return statement; + } + + private static string GetStringLiteral(ExpressionSyntax expr) + { + if (expr is LiteralExpressionSyntax literal) + return literal.Token.ValueText; + return expr.ToString().Trim('"'); + } +} + +/// +/// Shared Roslyn syntax extensions for workflow parsing +/// +internal static class RoslynExtensions +{ + /// + /// Gets the 1-based line number for a syntax node + /// + public static int GetLineNumber(this SyntaxNode node) + { + var location = node.GetLocation(); + var lineSpan = location.GetLineSpan(); + return lineSpan.StartLinePosition.Line + 1; + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockStructureRecognizer.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockStructureRecognizer.cs new file mode 100644 index 00000000..33b5b8c5 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockStructureRecognizer.cs @@ -0,0 +1,194 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.CodeAnalysis.Text; +using KitX.Core.Contract.Workflow; +using Serilog; + +namespace KitX.Core.Workflow.BlockScripting; + +/// +/// Recognizes block structure using source text scanning. +/// DSL Syntax: +/// #ConstBlock +/// #PubVarBlock +/// #MainBlock +/// #Block BlockName +/// Next block start = previous block end (no explicit end marker needed) +/// Block markers must be on their own line (excluding whitespace) +/// +internal class BlockStructureRecognizer +{ + private readonly string _sourceCode; + private readonly SourceText _sourceText; + private readonly List _blocks = new(); + + public bool HasErrors { get; private set; } + public string ErrorMessage { get; private set; } = string.Empty; + public int ErrorLine { get; private set; } + + public IReadOnlyList Blocks => _blocks; + + public BlockStructureRecognizer(string sourceCode) + { + _sourceCode = sourceCode; + _sourceText = SourceText.From(sourceCode); + } + + /// + /// Recognizes block structure from source code + /// + public void RecognizeBlocks() + { + if (string.IsNullOrWhiteSpace(_sourceCode)) + { + HasErrors = true; + ErrorMessage = "Source code is empty"; + ErrorLine = 0; + return; + } + + var lines = _sourceCode.Split('\n'); + RecognizedBlock? currentBlock = null; + int currentBlockContentStart = -1; + + for (int i = 0; i < lines.Length; i++) + { + var line = lines[i]; + var trimmed = line.Trim(); + + // Check if this line is a block marker + var blockType = TryParseBlockMarker(trimmed); + if (blockType.HasValue) + { + // Finish previous block + if (currentBlock != null) + { + currentBlock.ContentEnd = GetLineStartPosition(i); + currentBlock.Content = ExtractPureCode(currentBlockContentStart, currentBlock.ContentEnd); + _blocks.Add(currentBlock); + } + + // Extract block name for NamedBlock + var blockName = string.Empty; + if (blockType == BlockType.NamedBlock) + { + blockName = trimmed.Substring("#Block ".Length).Trim(); + } + + // Start new block + currentBlock = new RecognizedBlock + { + BlockType = blockType.Value, + BlockName = blockName, + StartLine = i + 1, + ContentStart = GetLineStartPosition(i + 1), // Next line + ContentEnd = -1 + }; + currentBlockContentStart = currentBlock.ContentStart; + } + } + + // Handle last block + if (currentBlock != null) + { + currentBlock.ContentEnd = _sourceCode.Length; + currentBlock.Content = ExtractPureCode(currentBlockContentStart, currentBlock.ContentEnd); + _blocks.Add(currentBlock); + } + + // Validate: must have MainBlock + if (!_blocks.Any(b => b.BlockType == BlockType.MainBlock)) + { + HasErrors = true; + ErrorMessage = "Script must have a #MainBlock"; + ErrorLine = 0; + } + } + + /// + /// Gets the character position in source code for the start of a line (0-indexed) + /// + private int GetLineStartPosition(int zeroBasedLineIndex) + { + if (zeroBasedLineIndex <= 0) return 0; + if (zeroBasedLineIndex >= _sourceText.Lines.Count) return _sourceCode.Length; + + return _sourceText.Lines[zeroBasedLineIndex].Start; + } + + /// + /// Attempts to parse a block marker line + /// + private BlockType? TryParseBlockMarker(string trimmed) + { + // #ConstBlock + if (trimmed == "#ConstBlock") + return BlockType.ConstBlock; + + // #PubVarBlock + if (trimmed == "#PubVarBlock") + return BlockType.PubVarBlock; + + // #MainBlock + if (trimmed == "#MainBlock") + return BlockType.MainBlock; + + // #Block Name + if (trimmed.StartsWith("#Block ")) + { + var name = trimmed.Substring("#Block ".Length).Trim(); + if (!string.IsNullOrEmpty(name)) + return BlockType.NamedBlock; + } + + return null; + } + + /// + /// Extracts pure C# code from content range, removing block marker lines + /// + private string ExtractPureCode(int start, int end) + { + if (start >= end || start < 0 || end > _sourceCode.Length) + return string.Empty; + + var code = _sourceCode.Substring(start, end - start); + var lines = code.Split('\n'); + var pureLines = new List(); + + foreach (var line in lines) + { + var trimmed = line.Trim(); + + // Skip block marker lines + if (trimmed == "#ConstBlock" || + trimmed == "#PubVarBlock" || + trimmed == "#MainBlock" || + trimmed.StartsWith("#Block ")) + { + continue; + } + + pureLines.Add(line); + } + + var result = string.Join("\n", pureLines).Trim(); + Log.Debug("[BlockStructureRecognizer] Extracted pure code ({Length} chars): {Preview}", + result.Length, result.Length > 100 ? result.Substring(0, 100) + "..." : result); + + return result; + } +} + +/// +/// Represents a recognized block with its type, name, and source position +/// +internal class RecognizedBlock +{ + public BlockType BlockType { get; set; } + public string BlockName { get; set; } = string.Empty; + public int StartLine { get; set; } + public int ContentStart { get; set; } + public int ContentEnd { get; set; } + public string Content { get; set; } = string.Empty; +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockSyntaxValidator.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockSyntaxValidator.cs new file mode 100644 index 00000000..ed601581 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockSyntaxValidator.cs @@ -0,0 +1,194 @@ +using System; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using KitX.Core.Contract.Workflow; + +namespace KitX.Core.Workflow.BlockScripting; + +/// +/// Validates pure C# code within recognized blocks using Roslyn. +/// Phase 2 of BlockScriptParser: validates syntax and block-type-specific rules. +/// +internal class BlockSyntaxValidator +{ + /// + /// Validates pure C# code within a block using Roslyn + /// + public BlockValidationResult Validate(RecognizedBlock recognized) + { + var result = new BlockValidationResult { IsValid = true }; + + if (string.IsNullOrWhiteSpace(recognized.Content)) + { + return result; // Empty blocks are OK + } + + try + { + // Parse the pure C# code + var syntaxTree = CSharpSyntaxTree.ParseText(recognized.Content); + var root = syntaxTree.GetRoot(); + result.ParsedRoot = root; // Save for reuse by statement extraction + var diagnostics = syntaxTree.GetDiagnostics(); + + // Check for syntax errors + foreach (var diag in diagnostics) + { + if (diag.Severity == DiagnosticSeverity.Error) + { + result.IsValid = false; + result.ErrorMessage = $"[{recognized.BlockType}] Syntax error: {diag.GetMessage()}"; + result.ErrorLine = recognized.StartLine + (int)diag.Location.GetLineSpan().StartLinePosition.Line; + return result; + } + } + + // Validate block-type-specific rules + ValidateBlockSpecificRules(recognized, root, result); + + return result; + } + catch (Exception ex) + { + result.IsValid = false; + result.ErrorMessage = $"[{recognized.BlockType}] Parse error: {ex.Message}"; + result.ErrorLine = recognized.StartLine; + return result; + } + } + + /// + /// Validates block-type-specific rules + /// + private void ValidateBlockSpecificRules(RecognizedBlock recognized, SyntaxNode root, BlockValidationResult result) + { + // Get all statements in the block + var statements = root.DescendantNodes() + .Where(n => n is StatementSyntax) + .Cast() + .ToList(); + + foreach (var stmt in statements) + { + switch (recognized.BlockType) + { + case BlockType.ConstBlock: + // ConstBlock: only variable declarations allowed, initializers are OK (const values) + if (stmt is not LocalDeclarationStatementSyntax) + { + result.IsValid = false; + result.ErrorMessage = $"[{recognized.BlockType}] Only variable declarations are allowed. Found: {stmt.Kind()}"; + result.ErrorLine = recognized.StartLine + stmt.GetLineNumber(); + return; + } + break; + + case BlockType.PubVarBlock: + // PubVarBlock: only variable declarations allowed, but NO initializers + if (stmt is not LocalDeclarationStatementSyntax) + { + result.IsValid = false; + result.ErrorMessage = $"[{recognized.BlockType}] Only variable declarations are allowed. Found: {stmt.Kind()}"; + result.ErrorLine = recognized.StartLine + stmt.GetLineNumber(); + return; + } + + // PubVarBlock declarations must NOT have initializers + if (stmt is LocalDeclarationStatementSyntax varDecl) + { + foreach (var variable in varDecl.Declaration.Variables) + { + if (variable.Initializer != null) + { + result.IsValid = false; + result.ErrorMessage = $"[{recognized.BlockType}] PubVarBlock variable declarations cannot have initializers. Variable '{variable.Identifier.Text}' has an initializer."; + result.ErrorLine = recognized.StartLine + stmt.GetLineNumber(); + return; + } + } + } + + // PubVarBlock must not contain assignment operations + if (stmt is ExpressionStatementSyntax exprStmt) + { + var hasAssignment = exprStmt.DescendantNodes().Any(n => n is AssignmentExpressionSyntax); + if (hasAssignment) + { + result.IsValid = false; + result.ErrorMessage = $"[{recognized.BlockType}] PubVarBlock cannot contain assignment operations."; + result.ErrorLine = recognized.StartLine + stmt.GetLineNumber(); + return; + } + } + break; + + case BlockType.MainBlock: + case BlockType.NamedBlock: + // MainBlock and NamedBlock: no variable declarations allowed + if (stmt is LocalDeclarationStatementSyntax) + { + result.IsValid = false; + result.ErrorMessage = $"[{recognized.BlockType}] Variable declarations are not allowed. Use ConstBlock or PubVarBlock instead."; + result.ErrorLine = recognized.StartLine + stmt.GetLineNumber(); + return; + } + + // Check for prohibited syntax (if/else/for/while/try/catch) + if (ContainsProhibitedSyntax(stmt)) + { + result.IsValid = false; + result.ErrorMessage = $"[{recognized.BlockType}] Prohibited syntax found: if/else/for/while/try/catch are not allowed"; + result.ErrorLine = recognized.StartLine + stmt.GetLineNumber(); + return; + } + break; + } + } + } + + /// + /// Checks if a statement contains prohibited syntax (if/else/for/while/try/catch) + /// + private static bool ContainsProhibitedSyntax(StatementSyntax statement) + { + // Check the statement itself + if (statement is IfStatementSyntax || + statement is ForStatementSyntax || + statement is ForEachStatementSyntax || + statement is WhileStatementSyntax || + statement is DoStatementSyntax || + statement is TryStatementSyntax) + { + return true; + } + + // Recursively check child statements + foreach (var child in statement.DescendantNodes()) + { + if (child is IfStatementSyntax || + child is ForStatementSyntax || + child is ForEachStatementSyntax || + child is WhileStatementSyntax || + child is DoStatementSyntax || + child is TryStatementSyntax) + { + return true; + } + } + + return false; + } +} + +/// +/// Result of validating a block's C# code +/// +internal class BlockValidationResult +{ + public bool IsValid { get; set; } = true; + public string ErrorMessage { get; set; } = string.Empty; + public int ErrorLine { get; set; } + public SyntaxNode? ParsedRoot { get; set; } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintToBlockScriptConverter.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintToBlockScriptConverter.cs index 0a40cc78..fa7f6469 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintToBlockScriptConverter.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintToBlockScriptConverter.cs @@ -1,8 +1,6 @@ -using System; using System.Collections.Generic; using System.Linq; using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.Blueprint.Pipeline; using KitX.Core.Workflow.Blueprint.ReversePipeline; using Serilog; @@ -12,35 +10,33 @@ namespace KitX.Core.Workflow.Blueprint; /// Converts Blueprint back to a fully-expanded BlockScript source code. /// Thin orchestrator that delegates to pipeline phases: /// Phase 1: BlueprintAnalyzer — data index construction -/// Phase 2: Walk execution flow (BlockScopes or topology path) -/// Phase 3: ConditionDuplicator — loop condition duplication +/// Phase 2: ExecutionFlowWalker — walk execution flow (BlockScopes or topology path) +/// Phase 3: ConditionDuplicator — loop condition duplication (topology path only) /// Phase 4: BlockScriptAssembler — source code assembly -/// Also implements INodeExportHelper for use by Export Strategies. /// -public class BlueprintToBlockScriptConverter : IBlueprintToBlockScriptConverter, INodeExportHelper +public class BlueprintToBlockScriptConverter : IBlueprintToBlockScriptConverter { - private readonly Dictionary _strategies; private readonly BlueprintAnalyzer _analyzer = new(); private readonly ConditionDuplicator _conditionDuplicator = new(); private readonly BlockScriptAssembler _assembler = new(); - - /// - /// Current conversion context — set during ConvertToBlockScript, accessible by INodeExportHelper methods. - /// - private ReverseConversionContext? _currentCtx; + private readonly NodeExportHelper _exportHelper = new(); public BlueprintToBlockScriptConverter(IEnumerable strategies) { - _strategies = strategies.ToDictionary(s => s.NodeType); + var strategyMap = strategies.ToDictionary(s => s.NodeType); + Walker = new ExecutionFlowWalker(strategyMap, _exportHelper); } - /// - public Contract.Workflow.Blueprint Blueprint { get; private set; } = null!; + /// Execution flow walker — accessible for testing + internal ExecutionFlowWalker Walker { get; } // ────────────────────────────────────────────── // Public API // ────────────────────────────────────────────── + /// + public Contract.Workflow.Blueprint Blueprint { get; private set; } = null!; + /// public string Convert(Contract.Workflow.Blueprint blueprint) => ConvertToBlockScript(blueprint).SourceCode; @@ -61,62 +57,13 @@ private BlockScript ConvertWithBlockScopes(Contract.Workflow.Blueprint blueprint { Blueprint = blueprint; var ctx = new ReverseConversionContext { Blueprint = blueprint, Script = new BlockScript() }; - _currentCtx = ctx; + _exportHelper.SetContext(blueprint, ctx); // Phase 1 _analyzer.Analyze(ctx); - // Build scopesByName lookup - foreach (var scope in blueprint.BlockScopes) - ctx.ScopesByName[scope.Name] = scope; - // Phase 2: Generate statements from stored block membership - foreach (var scope in blueprint.BlockScopes) - { - BlockDefinition block; - if (scope.IsMainBlock) - { - block = new BlockDefinition { Type = BlockType.MainBlock, Name = "MainBlock" }; - ctx.Script.MainBlock = block; - } - else - { - block = new BlockDefinition { Type = BlockType.NamedBlock, Name = scope.Name }; - ctx.Script.NamedBlocks[scope.Name] = block; - } - - foreach (var nodeId in scope.NodeIds) - { - if (!ctx.NodeById.TryGetValue(nodeId, out var node)) continue; - var stmt = GenerateStatement(node, ctx); - if (stmt != null) - { - block.Statements.Add(stmt); - if (stmt is FlowControlStatement flow) - { - ctx.ControlFlowMap[node.Id] = flow; - if (flow.ControlType == FlowControlType.Loop) - ctx.LoopNodes[node.Id] = node; - } - } - } - } - - // Resolve control flow block names from stored ownership - foreach (var scope in blueprint.BlockScopes) - { - if (scope.OwnerNodeId == null) continue; - if (!ctx.ControlFlowMap.TryGetValue(scope.OwnerNodeId, out var flow)) continue; - if (scope.OwnerArmName == "True" || scope.OwnerArmName == "LoopBody") - flow.TrueBlockName = scope.Name; - else if (scope.OwnerArmName == "False" || scope.OwnerArmName == "LoopEnd") - flow.FalseBlockName = scope.Name; - } - - foreach (var flow in ctx.ControlFlowMap.Values) - flow.RegenerateSourceCode(); - - DetectAndInsertLoopBodyEnds(blueprint, ctx); + Walker.WalkFromBlockScopes(blueprint, ctx); // Phase 4 _assembler.Assemble(ctx); @@ -127,13 +74,13 @@ private BlockScript ConvertWithTopology(Contract.Workflow.Blueprint blueprint) { Blueprint = blueprint; var ctx = new ReverseConversionContext { Blueprint = blueprint, Script = new BlockScript() }; - _currentCtx = ctx; + _exportHelper.SetContext(blueprint, ctx); // Phase 1 _analyzer.Analyze(ctx); // Phase 2: Walk execution flow - WalkExecutionFlow(ctx); + Walker.WalkExecutionFlow(ctx); // Phase 3: Duplicate loop conditions _conditionDuplicator.Duplicate(ctx); @@ -142,509 +89,4 @@ private BlockScript ConvertWithTopology(Contract.Workflow.Blueprint blueprint) _assembler.Assemble(ctx); return ctx.Script; } - - // ────────────────────────────────────────────── - // INodeExportHelper - // ────────────────────────────────────────────── - - /// - public string GetInputValue(BlueprintNode node, string pinName) - { - var pin = node.InputPins.FirstOrDefault(p => p.Name == pinName); - if (pin == null) return string.Empty; - - var dataConn = Blueprint.Connections.FirstOrDefault(c => c.TargetPinId == pin.Id); - if (dataConn == null) return FormatLiteralValue(pin.DefaultValue ?? string.Empty, _currentCtx); - - var sourceNode = Blueprint.GetNodeById(dataConn.SourceNodeId); - if (sourceNode is ConstNode constNode) - return constNode.ConstName; - - return dataConn.PubVarName ?? FormatLiteralValue(pin.DefaultValue ?? string.Empty, _currentCtx); - } - - /// - public string GetInputArgs(BlueprintNode node) - { - var args = new List(); - foreach (var pin in node.InputPins) - { - if (pin.Name != "Exec") - args.Add(GetInputValue(node, pin.Name)); - } - return string.Join(", ", args); - } - - // ────────────────────────────────────────────── - // Phase 2: Walk Execution Flow (topology path) - // ────────────────────────────────────────────── - - private void WalkExecutionFlow(ReverseConversionContext ctx) - { - var entryNode = ctx.Blueprint.Nodes.FirstOrDefault(n => n.NodeType == BlueprintNodeType.Entry); - if (entryNode == null) - { - Log.Warning("[BlueprintToScript] No Entry node found"); - return; - } - - var mainBlock = new BlockDefinition { Type = BlockType.MainBlock, Name = "MainBlock" }; - ctx.Script.MainBlock = mainBlock; - - var visited = new HashSet(); - WalkNode(entryNode, mainBlock, ctx, visited, loopbackTargetId: null); - - ProcessSubGraphs(ctx); - } - - private void WalkNode(BlueprintNode node, BlockDefinition currentBlock, - ReverseConversionContext ctx, HashSet visited, string? loopbackTargetId) - { - if (visited.Contains(node.Id)) return; - visited.Add(node.Id); - - if (loopbackTargetId != null && node.Id == loopbackTargetId) - { - currentBlock.Statements.Add(CreateLoopBodyEndStatement()); - return; - } - - var stmt = GenerateStatement(node, ctx); - if (stmt != null) - { - currentBlock.Statements.Add(stmt); - if (stmt is FlowControlStatement flow) - { - ctx.ControlFlowMap[node.Id] = flow; - if (flow.ControlType == FlowControlType.Loop) - ctx.LoopNodes[node.Id] = node; - } - } - - if (node.NodeType is BlueprintNodeType.Branch or BlueprintNodeType.Loop) - { - ctx.PendingControlFlowNodes.Add(node); - return; - } - - var execOut = node.OutputPins.FirstOrDefault(p => p.Name == "Exec"); - if (execOut == null) - { - if (loopbackTargetId != null) - currentBlock.Statements.Add(CreateLoopBodyEndStatement()); - return; - } - - var execConn = ctx.ExecConnections.FirstOrDefault(c => c.SourcePinId == execOut.Id); - if (execConn == null) - { - if (loopbackTargetId != null) - currentBlock.Statements.Add(CreateLoopBodyEndStatement()); - return; - } - - var nextNode = ctx.Blueprint.GetNodeById(execConn.TargetNodeId); - if (nextNode == null) return; - - if (loopbackTargetId != null && nextNode.Id == loopbackTargetId) - { - currentBlock.Statements.Add(CreateLoopBodyEndStatement()); - return; - } - - WalkNode(nextNode, currentBlock, ctx, visited, loopbackTargetId); - } - - // ────────────────────────────────────────────── - // Statement generation (strategy dispatch) - // ────────────────────────────────────────────── - - private static FlowControlStatement CreateLoopBodyEndStatement(string? returnTo = null) - { - var stmt = new FlowControlStatement - { - ControlType = FlowControlType.LoopBodyEnd, - LineNumber = 1, - LoopBodyEndReturnTo = returnTo - }; - stmt.RegenerateSourceCode(); - return stmt; - } - - /// - /// Generates a BlockStatement for the given node using Export Strategies, - /// with post-processing for PubVar assignments. - /// - private BlockStatement? GenerateStatement(BlueprintNode node, ReverseConversionContext ctx) - { - switch (node.NodeType) - { - case BlueprintNodeType.Entry: - return null; - case BlueprintNodeType.Break: - return new FlowControlStatement - { - ControlType = FlowControlType.Break, - SourceCode = "Break();", - LineNumber = 1 - }; - case BlueprintNodeType.Get: - return GenerateGetStatementViaStrategy(node, ctx); - default: - break; - } - - if (!_strategies.TryGetValue(node.NodeType, out var strategy)) - { - Log.Warning("[BlueprintToScript] Unhandled node type: {NodeType}", node.NodeType); - return null; - } - - var stmt = strategy.ToStatement(node, this); - if (stmt == null) return null; - - // Post-process: add PubVar prefix for Call/CallHelper with consumed Return - if (node.NodeType is BlueprintNodeType.Call or BlueprintNodeType.CallHelper - && stmt is ExpressionStatement exprStmt) - { - var returnPin = node.OutputPins.FirstOrDefault(p => p.Name == "Return"); - bool hasReturn = returnPin != null && ctx.ConsumedOutputs.Contains((node.Id, "Return")); - if (hasReturn) - { - var pubVar = FindOutputPubVar(node, "Return", ctx); - if (pubVar != null) - exprStmt.SourceCode = $"{pubVar} = {exprStmt.Expression};"; - } - } - - return stmt; - } - - private BlockStatement? GenerateGetStatementViaStrategy(BlueprintNode node, ReverseConversionContext ctx) - { - if (!ctx.ConsumedOutputs.Contains((node.Id, "Value"))) - return null; - - var pubVar = FindOutputPubVar(node, "Value", ctx); - if (pubVar == null) return null; - - if (!_strategies.TryGetValue(BlueprintNodeType.Get, out var strategy)) - return null; - - var stmt = strategy.ToStatement(node, this); - if (stmt is ExpressionStatement exprStmt) - { - exprStmt.SourceCode = $"{pubVar} = {exprStmt.Expression};"; - return exprStmt; - } - return stmt; - } - - // ────────────────────────────────────────────── - // Input Resolution - // ────────────────────────────────────────────── - - private string ResolveInputValue(BlueprintNode node, string pinName, ReverseConversionContext ctx) - { - var pin = node.InputPins.FirstOrDefault(p => p.Name == pinName); - if (pin == null) return string.Empty; - - if (ctx.InputDataMap.TryGetValue((node.Id, pin.Name), out var info)) - { - if (info.SourceNode is ConstNode constNode) - return constNode.ConstName; - if (!string.IsNullOrEmpty(info.PubVarName)) - return info.PubVarName; - } - - var defaultValue = pin.DefaultValue ?? string.Empty; - return FormatLiteralValue(defaultValue, ctx); - } - - private static string FormatLiteralValue(string value, ReverseConversionContext? ctx = null) - { - if (string.IsNullOrEmpty(value)) return value; - if (value.StartsWith("\"")) return value; - if (ctx != null && ctx.AllPubVars.Contains(value)) return value; - if (int.TryParse(value, out _) || double.TryParse(value, out _)) return value; - if (value == "true" || value == "false") return value; - if (value.Contains("(")) return value; - if (ctx != null && ctx.Blueprint.Nodes.OfType().Any(c => c.ConstName == value)) - return value; - return $"\"{value}\""; - } - - private string? FindOutputPubVar(BlueprintNode node, string pinName, ReverseConversionContext ctx) - { - var pin = node.OutputPins.FirstOrDefault(p => p.Name == pinName); - if (pin == null) return null; - var conn = ctx.DataConnections.FirstOrDefault(c => c.SourcePinId == pin.Id); - return conn?.PubVarName; - } - - // ────────────────────────────────────────────── - // Sub-graph Processing - // ────────────────────────────────────────────── - - private void ProcessSubGraphs(ReverseConversionContext ctx) - { - var processedTargets = new HashSet(); - var processedNodes = new HashSet(); - - while (ctx.PendingControlFlowNodes.Count > 0) - { - var pendingNodes = ctx.PendingControlFlowNodes.ToList(); - ctx.PendingControlFlowNodes.Clear(); - - foreach (var node in pendingNodes) - { - if (processedNodes.Contains(node.Id)) continue; - processedNodes.Add(node.Id); - - switch (node.NodeType) - { - case BlueprintNodeType.Branch: - ProcessBranchSubGraphs(node, ctx, processedTargets); - break; - case BlueprintNodeType.Loop: - ProcessLoopSubGraphs(node, ctx, processedTargets); - break; - } - } - } - - foreach (var kvp in ctx.ControlFlowMap) - { - var flow = kvp.Value; - if (ctx.BlockNameAssignments.TryGetValue(kvp.Key, out var assignments)) - { - flow.TrueBlockName = assignments.TrueBlockName; - flow.FalseBlockName = assignments.FalseBlockName; - flow.RegenerateSourceCode(); - } - } - } - - private void ProcessBranchSubGraphs(BlueprintNode branchNode, ReverseConversionContext ctx, - HashSet processedTargets) - { - var trueBlockName = string.Empty; - var falseBlockName = string.Empty; - var currentLoopback = ctx.CurrentLoopbackTargetId; - - foreach (var pinName in new[] { "True", "False" }) - { - var pin = branchNode.OutputPins.FirstOrDefault(p => p.Name == pinName); - if (pin == null) continue; - - var conn = ctx.ExecConnections.FirstOrDefault(c => c.SourcePinId == pin.Id); - if (conn == null || processedTargets.Contains(conn.TargetNodeId)) continue; - - var targetNode = ctx.Blueprint.GetNodeById(conn.TargetNodeId); - if (targetNode == null) continue; - - var blockName = $"Block_{ctx.BlockCounter++}"; - var block = new BlockDefinition { Type = BlockType.NamedBlock, Name = blockName }; - - WalkNode(targetNode, block, ctx, new HashSet(), loopbackTargetId: currentLoopback); - - ctx.Script.NamedBlocks[blockName] = block; - processedTargets.Add(conn.TargetNodeId); - - if (pinName == "True") trueBlockName = blockName; - else falseBlockName = blockName; - } - - ctx.BlockNameAssignments[branchNode.Id] = (trueBlockName, falseBlockName); - } - - private void ProcessLoopSubGraphs(BlueprintNode loopNode, ReverseConversionContext ctx, - HashSet processedTargets) - { - var loopBodyBlockName = string.Empty; - var loopEndBlockName = string.Empty; - - var loopBodyPin = loopNode.OutputPins.FirstOrDefault(p => p.Name == "LoopBody"); - if (loopBodyPin != null) - { - var conn = ctx.ExecConnections.FirstOrDefault(c => c.SourcePinId == loopBodyPin.Id); - if (conn != null && !processedTargets.Contains(conn.TargetNodeId)) - { - var targetNode = ctx.Blueprint.GetNodeById(conn.TargetNodeId); - if (targetNode != null) - { - loopBodyBlockName = $"Block_{ctx.BlockCounter++}"; - var block = new BlockDefinition { Type = BlockType.NamedBlock, Name = loopBodyBlockName }; - - ctx.CurrentLoopbackTargetId = loopNode.Id; - WalkNode(targetNode, block, ctx, new HashSet(), loopbackTargetId: loopNode.Id); - - ctx.Script.NamedBlocks[loopBodyBlockName] = block; - processedTargets.Add(conn.TargetNodeId); - ctx.LoopBodyBlocks[loopNode.Id] = block; - } - } - } - - var loopEndPin = loopNode.OutputPins.FirstOrDefault(p => p.Name == "LoopEnd"); - if (loopEndPin != null) - { - var conn = ctx.ExecConnections.FirstOrDefault(c => c.SourcePinId == loopEndPin.Id); - if (conn != null && !processedTargets.Contains(conn.TargetNodeId)) - { - var targetNode = ctx.Blueprint.GetNodeById(conn.TargetNodeId); - if (targetNode != null) - { - loopEndBlockName = $"Block_{ctx.BlockCounter++}"; - var block = new BlockDefinition { Type = BlockType.NamedBlock, Name = loopEndBlockName }; - - WalkNode(targetNode, block, ctx, new HashSet(), loopbackTargetId: null); - - ctx.Script.NamedBlocks[loopEndBlockName] = block; - processedTargets.Add(conn.TargetNodeId); - } - } - } - - ctx.BlockNameAssignments[loopNode.Id] = (loopBodyBlockName, loopEndBlockName); - } - - // ────────────────────────────────────────────── - // BlockScope helpers - // ────────────────────────────────────────────── - - private void DetectAndInsertLoopBodyEnds(Contract.Workflow.Blueprint bp, ReverseConversionContext ctx) - { - foreach (var scope in bp.BlockScopes) - { - if (scope.NodeIds.Count == 0) continue; - // Skip scopes that themselves own a Loop node — they are the Loop's parent, - // not a LoopBodyEnd target. - if (scope.NodeIds.Any(id => ctx.NodeById.TryGetValue(id, out var n) && n.NodeType == BlueprintNodeType.Loop)) - continue; - - var lastNodeId = scope.NodeIds[scope.NodeIds.Count - 1]; - if (!ctx.NodeById.TryGetValue(lastNodeId, out var lastNode)) continue; - - // Follow the exec chain from the last node to find if it reaches a Loop node. - // Shared nodes (reused via PubVar) may sit between the scope's last node - // and the Loop, so we must follow the chain through multiple hops. - var (loopNode, pathNodes) = FollowExecChainToLoop(lastNode, ctx); - if (loopNode == null) continue; - - // Don't insert LoopBodyEnd if this scope IS the scope containing the Loop - var loopScopeName = FindScopeContainingNode(loopNode.Id, bp); - if (loopScopeName == scope.Name) continue; - - var returnToBlock = loopScopeName; - if (returnToBlock == null) continue; - - BlockDefinition? blockDef = scope.IsMainBlock - ? ctx.Script.MainBlock - : ctx.Script.NamedBlocks.GetValueOrDefault(scope.Name); - if (blockDef == null) continue; - - // Generate condition re-evaluation statements for the Loop's condition - // by tracing the data chain from Loop.Condition back to source nodes. - GenerateConditionStatements(loopNode, blockDef, ctx); - - blockDef.Statements.Add(CreateLoopBodyEndStatement(returnToBlock)); - } - } - - /// - /// Follows the exec output chain from a node until reaching a Loop node. - /// Returns the Loop node (if found) and the intermediate nodes traversed. - /// - private (BlueprintNode? loopNode, List path) FollowExecChainToLoop( - BlueprintNode startNode, ReverseConversionContext ctx) - { - var path = new List(); - var current = startNode; - var visited = new HashSet(); - - while (current != null && !visited.Contains(current.Id)) - { - visited.Add(current.Id); - - if (current.NodeType == BlueprintNodeType.Loop) - return (current, path); - - var execOutPin = current.OutputPins.FirstOrDefault(p => p.Name == "Exec"); - if (execOutPin == null) break; - - var execConn = ctx.ExecConnections.FirstOrDefault(c => c.SourcePinId == execOutPin.Id); - if (execConn == null) break; - - if (!ctx.NodeById.TryGetValue(execConn.TargetNodeId, out var next)) break; - - path.Add(next); - current = next; - } - - return (null, path); - } - - /// - /// Generates condition re-evaluation statements for a Loop node by tracing - /// the data chain from Loop.Condition back through source nodes. - /// Produces statements like: - /// vaaa0001 = Get("currentLoop"); - /// vaaa0002 = HelperFuncCompare("BLE", vaaa0001, loopMax); - /// - private void GenerateConditionStatements(BlueprintNode loopNode, BlockDefinition blockDef, - ReverseConversionContext ctx) - { - var condPin = loopNode.InputPins.FirstOrDefault(p => p.Name == "Condition"); - if (condPin == null) return; - - // Find data connection feeding into Loop.Condition - var condConn = ctx.DataConnections.FirstOrDefault(c => c.TargetPinId == condPin.Id); - if (condConn == null) return; - - // Trace and generate statements in reverse order (deepest source first) - var generated = new HashSet(); - GenerateDataChainStatements(condConn.SourceNodeId, blockDef, ctx, generated); - } - - /// - /// Recursively generates assignment statements for the data source chain. - /// E.g., if HelperFuncCompare feeds into Loop.Condition, and Get feeds into HelperFuncCompare, - /// this generates: Get → HelperFuncCompare statements in order. - /// - private void GenerateDataChainStatements(string sourceNodeId, - BlockDefinition blockDef, ReverseConversionContext ctx, HashSet generated) - { - if (generated.Contains(sourceNodeId)) return; - if (!ctx.NodeById.TryGetValue(sourceNodeId, out var sourceNode)) return; - - // First, recursively generate any upstream data dependencies - foreach (var inputPin in sourceNode.InputPins) - { - if (inputPin.Name == "Exec") continue; - var upConn = ctx.DataConnections.FirstOrDefault(c => c.TargetPinId == inputPin.Id); - if (upConn != null) - GenerateDataChainStatements(upConn.SourceNodeId, blockDef, ctx, generated); - } - - // Skip ConstNode — they are declared in ConstBlock, not inline - if (sourceNode.NodeType == BlueprintNodeType.Const) return; - - generated.Add(sourceNodeId); - - // Generate statement for this node - var stmt = GenerateStatement(sourceNode, ctx); - if (stmt != null) - blockDef.Statements.Add(stmt); - } - - private static string? FindScopeContainingNode(string nodeId, Contract.Workflow.Blueprint bp) - { - foreach (var scope in bp.BlockScopes) - { - if (scope.NodeIds.Contains(nodeId)) - return scope.Name; - } - return null; - } } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExecutionFlowWalker.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExecutionFlowWalker.cs new file mode 100644 index 00000000..9bc89d7c --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExecutionFlowWalker.cs @@ -0,0 +1,483 @@ +using System.Collections.Generic; +using System.Linq; +using KitX.Core.Contract.Workflow; +using KitX.Core.Workflow.Blueprint.ReversePipeline; +using Serilog; + +namespace KitX.Core.Workflow.Blueprint; + +/// +/// Phase 2 of bp2bs: walks the Blueprint execution flow graph and generates +/// BlockScript statements. Handles both BlockScopes-based and topology-based paths. +/// Extracted from BlueprintToBlockScriptConverter for single-responsibility. +/// +internal class ExecutionFlowWalker +{ + private readonly Dictionary _strategies; + private readonly NodeExportHelper _exportHelper; + + public ExecutionFlowWalker( + Dictionary strategies, + NodeExportHelper exportHelper) + { + _strategies = strategies; + _exportHelper = exportHelper; + } + + /// + /// Walks execution flow starting from Entry node (topology-based path). + /// Creates MainBlock and walks the execution graph depth-first. + /// + public void WalkExecutionFlow(ReverseConversionContext ctx) + { + var entryNode = ctx.Blueprint.Nodes.FirstOrDefault(n => n.NodeType == BlueprintNodeType.Entry); + if (entryNode == null) + { + Log.Warning("[ExecutionFlowWalker] No Entry node found"); + return; + } + + var mainBlock = new BlockDefinition { Type = BlockType.MainBlock, Name = "MainBlock" }; + ctx.Script.MainBlock = mainBlock; + + var visited = new HashSet(); + WalkNode(entryNode, mainBlock, ctx, visited, loopbackTargetId: null); + + ProcessSubGraphs(ctx); + } + + /// + /// Generates statements from stored BlockScopes (BlockScopes-based path). + /// Processes each scope, generates statements for its nodes, resolves control flow, + /// and inserts LoopBodyEnd statements. + /// + public void WalkFromBlockScopes(Contract.Workflow.Blueprint blueprint, ReverseConversionContext ctx) + { + // Build scopesByName lookup + foreach (var scope in blueprint.BlockScopes) + ctx.ScopesByName[scope.Name] = scope; + + // Generate statements from stored block membership + foreach (var scope in blueprint.BlockScopes) + { + BlockDefinition block; + if (scope.IsMainBlock) + { + block = new BlockDefinition { Type = BlockType.MainBlock, Name = "MainBlock" }; + ctx.Script.MainBlock = block; + } + else + { + block = new BlockDefinition { Type = BlockType.NamedBlock, Name = scope.Name }; + ctx.Script.NamedBlocks[scope.Name] = block; + } + + foreach (var nodeId in scope.NodeIds) + { + if (!ctx.NodeById.TryGetValue(nodeId, out var node)) continue; + var stmt = GenerateStatement(node, ctx); + if (stmt != null) + { + block.Statements.Add(stmt); + if (stmt is FlowControlStatement flow) + { + ctx.ControlFlowMap[node.Id] = flow; + if (flow.ControlType == FlowControlType.Loop) + ctx.LoopNodes[node.Id] = node; + } + } + } + } + + // Resolve control flow block names from stored ownership + foreach (var scope in blueprint.BlockScopes) + { + if (scope.OwnerNodeId == null) continue; + if (!ctx.ControlFlowMap.TryGetValue(scope.OwnerNodeId, out var flow)) continue; + if (scope.OwnerArmName == "True" || scope.OwnerArmName == "LoopBody") + flow.TrueBlockName = scope.Name; + else if (scope.OwnerArmName == "False" || scope.OwnerArmName == "LoopEnd") + flow.FalseBlockName = scope.Name; + } + + foreach (var flow in ctx.ControlFlowMap.Values) + flow.RegenerateSourceCode(); + + DetectAndInsertLoopBodyEnds(blueprint, ctx); + } + + // ─── Node Walking ──────────────────────────────────────────────────── + + private void WalkNode(BlueprintNode node, BlockDefinition currentBlock, + ReverseConversionContext ctx, HashSet visited, string? loopbackTargetId) + { + if (visited.Contains(node.Id)) return; + visited.Add(node.Id); + + if (loopbackTargetId != null && node.Id == loopbackTargetId) + { + currentBlock.Statements.Add(CreateLoopBodyEndStatement()); + return; + } + + var stmt = GenerateStatement(node, ctx); + if (stmt != null) + { + currentBlock.Statements.Add(stmt); + if (stmt is FlowControlStatement flow) + { + ctx.ControlFlowMap[node.Id] = flow; + if (flow.ControlType == FlowControlType.Loop) + ctx.LoopNodes[node.Id] = node; + } + } + + if (node.NodeType is BlueprintNodeType.Branch or BlueprintNodeType.Loop) + { + ctx.PendingControlFlowNodes.Add(node); + return; + } + + var execOut = node.OutputPins.FirstOrDefault(p => p.Name == "Exec"); + if (execOut == null) + { + if (loopbackTargetId != null) + currentBlock.Statements.Add(CreateLoopBodyEndStatement()); + return; + } + + var execConn = ctx.ExecConnections.FirstOrDefault(c => c.SourcePinId == execOut.Id); + if (execConn == null) + { + if (loopbackTargetId != null) + currentBlock.Statements.Add(CreateLoopBodyEndStatement()); + return; + } + + var nextNode = ctx.Blueprint.GetNodeById(execConn.TargetNodeId); + if (nextNode == null) return; + + if (loopbackTargetId != null && nextNode.Id == loopbackTargetId) + { + currentBlock.Statements.Add(CreateLoopBodyEndStatement()); + return; + } + + WalkNode(nextNode, currentBlock, ctx, visited, loopbackTargetId); + } + + // ─── Statement Generation ──────────────────────────────────────────── + + private static FlowControlStatement CreateLoopBodyEndStatement(string? returnTo = null) + { + var stmt = new FlowControlStatement + { + ControlType = FlowControlType.LoopBodyEnd, + LineNumber = 1, + LoopBodyEndReturnTo = returnTo + }; + stmt.RegenerateSourceCode(); + return stmt; + } + + /// + /// Generates a BlockStatement for the given node using Export Strategies, + /// with post-processing for PubVar assignments. + /// + private BlockStatement? GenerateStatement(BlueprintNode node, ReverseConversionContext ctx) + { + switch (node.NodeType) + { + case BlueprintNodeType.Entry: + return null; + case BlueprintNodeType.Break: + return new FlowControlStatement + { + ControlType = FlowControlType.Break, + SourceCode = "Break();", + LineNumber = 1 + }; + case BlueprintNodeType.Get: + return GenerateGetStatementViaStrategy(node, ctx); + default: + break; + } + + if (!_strategies.TryGetValue(node.NodeType, out var strategy)) + { + Log.Warning("[ExecutionFlowWalker] Unhandled node type: {NodeType}", node.NodeType); + return null; + } + + var stmt = strategy.ToStatement(node, _exportHelper); + if (stmt == null) return null; + + // Post-process: add PubVar prefix for Call/CallHelper with consumed Return + if (node.NodeType is BlueprintNodeType.Call or BlueprintNodeType.CallHelper + && stmt is ExpressionStatement exprStmt) + { + var returnPin = node.OutputPins.FirstOrDefault(p => p.Name == "Return"); + bool hasReturn = returnPin != null && ctx.ConsumedOutputs.Contains((node.Id, "Return")); + if (hasReturn) + { + var pubVar = NodeExportHelper.FindOutputPubVar(node, "Return", ctx); + if (pubVar != null) + exprStmt.SourceCode = $"{pubVar} = {exprStmt.Expression};"; + } + } + + return stmt; + } + + private BlockStatement? GenerateGetStatementViaStrategy(BlueprintNode node, ReverseConversionContext ctx) + { + if (!ctx.ConsumedOutputs.Contains((node.Id, "Value"))) + return null; + + var pubVar = NodeExportHelper.FindOutputPubVar(node, "Value", ctx); + if (pubVar == null) return null; + + if (!_strategies.TryGetValue(BlueprintNodeType.Get, out var strategy)) + return null; + + var stmt = strategy.ToStatement(node, _exportHelper); + if (stmt is ExpressionStatement exprStmt) + { + exprStmt.SourceCode = $"{pubVar} = {exprStmt.Expression};"; + return exprStmt; + } + return stmt; + } + + // ─── Sub-graph Processing ──────────────────────────────────────────── + + private void ProcessSubGraphs(ReverseConversionContext ctx) + { + var processedTargets = new HashSet(); + var processedNodes = new HashSet(); + + while (ctx.PendingControlFlowNodes.Count > 0) + { + var pendingNodes = ctx.PendingControlFlowNodes.ToList(); + ctx.PendingControlFlowNodes.Clear(); + + foreach (var node in pendingNodes) + { + if (processedNodes.Contains(node.Id)) continue; + processedNodes.Add(node.Id); + + switch (node.NodeType) + { + case BlueprintNodeType.Branch: + ProcessBranchSubGraphs(node, ctx, processedTargets); + break; + case BlueprintNodeType.Loop: + ProcessLoopSubGraphs(node, ctx, processedTargets); + break; + } + } + } + + foreach (var kvp in ctx.ControlFlowMap) + { + var flow = kvp.Value; + if (ctx.BlockNameAssignments.TryGetValue(kvp.Key, out var assignments)) + { + flow.TrueBlockName = assignments.TrueBlockName; + flow.FalseBlockName = assignments.FalseBlockName; + flow.RegenerateSourceCode(); + } + } + } + + private void ProcessBranchSubGraphs(BlueprintNode branchNode, ReverseConversionContext ctx, + HashSet processedTargets) + { + var trueBlockName = string.Empty; + var falseBlockName = string.Empty; + var currentLoopback = ctx.CurrentLoopbackTargetId; + + foreach (var pinName in new[] { "True", "False" }) + { + var pin = branchNode.OutputPins.FirstOrDefault(p => p.Name == pinName); + if (pin == null) continue; + + var conn = ctx.ExecConnections.FirstOrDefault(c => c.SourcePinId == pin.Id); + if (conn == null || processedTargets.Contains(conn.TargetNodeId)) continue; + + var targetNode = ctx.Blueprint.GetNodeById(conn.TargetNodeId); + if (targetNode == null) continue; + + var blockName = $"Block_{ctx.BlockCounter++}"; + var block = new BlockDefinition { Type = BlockType.NamedBlock, Name = blockName }; + + WalkNode(targetNode, block, ctx, new HashSet(), loopbackTargetId: currentLoopback); + + ctx.Script.NamedBlocks[blockName] = block; + processedTargets.Add(conn.TargetNodeId); + + if (pinName == "True") trueBlockName = blockName; + else falseBlockName = blockName; + } + + ctx.BlockNameAssignments[branchNode.Id] = (trueBlockName, falseBlockName); + } + + private void ProcessLoopSubGraphs(BlueprintNode loopNode, ReverseConversionContext ctx, + HashSet processedTargets) + { + var loopBodyBlockName = string.Empty; + var loopEndBlockName = string.Empty; + + var loopBodyPin = loopNode.OutputPins.FirstOrDefault(p => p.Name == "LoopBody"); + if (loopBodyPin != null) + { + var conn = ctx.ExecConnections.FirstOrDefault(c => c.SourcePinId == loopBodyPin.Id); + if (conn != null && !processedTargets.Contains(conn.TargetNodeId)) + { + var targetNode = ctx.Blueprint.GetNodeById(conn.TargetNodeId); + if (targetNode != null) + { + loopBodyBlockName = $"Block_{ctx.BlockCounter++}"; + var block = new BlockDefinition { Type = BlockType.NamedBlock, Name = loopBodyBlockName }; + + ctx.CurrentLoopbackTargetId = loopNode.Id; + WalkNode(targetNode, block, ctx, new HashSet(), loopbackTargetId: loopNode.Id); + + ctx.Script.NamedBlocks[loopBodyBlockName] = block; + processedTargets.Add(conn.TargetNodeId); + ctx.LoopBodyBlocks[loopNode.Id] = block; + } + } + } + + var loopEndPin = loopNode.OutputPins.FirstOrDefault(p => p.Name == "LoopEnd"); + if (loopEndPin != null) + { + var conn = ctx.ExecConnections.FirstOrDefault(c => c.SourcePinId == loopEndPin.Id); + if (conn != null && !processedTargets.Contains(conn.TargetNodeId)) + { + var targetNode = ctx.Blueprint.GetNodeById(conn.TargetNodeId); + if (targetNode != null) + { + loopEndBlockName = $"Block_{ctx.BlockCounter++}"; + var block = new BlockDefinition { Type = BlockType.NamedBlock, Name = loopEndBlockName }; + + WalkNode(targetNode, block, ctx, new HashSet(), loopbackTargetId: null); + + ctx.Script.NamedBlocks[loopEndBlockName] = block; + processedTargets.Add(conn.TargetNodeId); + } + } + } + + ctx.BlockNameAssignments[loopNode.Id] = (loopBodyBlockName, loopEndBlockName); + } + + // ─── BlockScope LoopBodyEnd Helpers ─────────────────────────────────── + + private void DetectAndInsertLoopBodyEnds(Contract.Workflow.Blueprint bp, ReverseConversionContext ctx) + { + foreach (var scope in bp.BlockScopes) + { + if (scope.NodeIds.Count == 0) continue; + if (scope.NodeIds.Any(id => ctx.NodeById.TryGetValue(id, out var n) && n.NodeType == BlueprintNodeType.Loop)) + continue; + + var lastNodeId = scope.NodeIds[scope.NodeIds.Count - 1]; + if (!ctx.NodeById.TryGetValue(lastNodeId, out var lastNode)) continue; + + var (loopNode, _) = FollowExecChainToLoop(lastNode, ctx); + if (loopNode == null) continue; + + var loopScopeName = FindScopeContainingNode(loopNode.Id, bp); + if (loopScopeName == scope.Name) continue; + + var returnToBlock = loopScopeName; + if (returnToBlock == null) continue; + + BlockDefinition? blockDef = scope.IsMainBlock + ? ctx.Script.MainBlock + : ctx.Script.NamedBlocks.GetValueOrDefault(scope.Name); + if (blockDef == null) continue; + + GenerateConditionStatements(loopNode, blockDef, ctx); + blockDef.Statements.Add(CreateLoopBodyEndStatement(returnToBlock)); + } + } + + private static (BlueprintNode? loopNode, List path) FollowExecChainToLoop( + BlueprintNode startNode, ReverseConversionContext ctx) + { + var path = new List(); + var current = startNode; + var visited = new HashSet(); + + while (current != null && !visited.Contains(current.Id)) + { + visited.Add(current.Id); + + if (current.NodeType == BlueprintNodeType.Loop) + return (current, path); + + var execOutPin = current.OutputPins.FirstOrDefault(p => p.Name == "Exec"); + if (execOutPin == null) break; + + var execConn = ctx.ExecConnections.FirstOrDefault(c => c.SourcePinId == execOutPin.Id); + if (execConn == null) break; + + if (!ctx.NodeById.TryGetValue(execConn.TargetNodeId, out var next)) break; + + path.Add(next); + current = next; + } + + return (null, path); + } + + private void GenerateConditionStatements(BlueprintNode loopNode, BlockDefinition blockDef, + ReverseConversionContext ctx) + { + var condPin = loopNode.InputPins.FirstOrDefault(p => p.Name == "Condition"); + if (condPin == null) return; + + var condConn = ctx.DataConnections.FirstOrDefault(c => c.TargetPinId == condPin.Id); + if (condConn == null) return; + + var generated = new HashSet(); + GenerateDataChainStatements(condConn.SourceNodeId, blockDef, ctx, generated); + } + + private void GenerateDataChainStatements(string sourceNodeId, + BlockDefinition blockDef, ReverseConversionContext ctx, HashSet generated) + { + if (generated.Contains(sourceNodeId)) return; + if (!ctx.NodeById.TryGetValue(sourceNodeId, out var sourceNode)) return; + + foreach (var inputPin in sourceNode.InputPins) + { + if (inputPin.Name == "Exec") continue; + var upConn = ctx.DataConnections.FirstOrDefault(c => c.TargetPinId == inputPin.Id); + if (upConn != null) + GenerateDataChainStatements(upConn.SourceNodeId, blockDef, ctx, generated); + } + + if (sourceNode.NodeType == BlueprintNodeType.Const) return; + + generated.Add(sourceNodeId); + + var stmt = GenerateStatement(sourceNode, ctx); + if (stmt != null) + blockDef.Statements.Add(stmt); + } + + private static string? FindScopeContainingNode(string nodeId, Contract.Workflow.Blueprint bp) + { + foreach (var scope in bp.BlockScopes) + { + if (scope.NodeIds.Contains(nodeId)) + return scope.Name; + } + return null; + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeExportHelper.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeExportHelper.cs new file mode 100644 index 00000000..0620acf7 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeExportHelper.cs @@ -0,0 +1,102 @@ +using System.Collections.Generic; +using System.Linq; +using KitX.Core.Contract.Workflow; +using KitX.Core.Workflow.Blueprint.ReversePipeline; + +namespace KitX.Core.Workflow.Blueprint; + +/// +/// Implements INodeExportHelper — resolves input values for export strategies +/// by tracing data connections, PubVar assignments, and const references. +/// Extracted from BlueprintToBlockScriptConverter for single-responsibility. +/// +internal class NodeExportHelper : INodeExportHelper +{ + /// + public Contract.Workflow.Blueprint Blueprint { get; private set; } = null!; + + /// Sets the current blueprint and context for resolution + public void SetContext(Contract.Workflow.Blueprint blueprint, ReverseConversionContext? ctx) + { + Blueprint = blueprint; + _currentCtx = ctx; + } + + private ReverseConversionContext? _currentCtx; + + /// + public string GetInputValue(BlueprintNode node, string pinName) + { + var pin = node.InputPins.FirstOrDefault(p => p.Name == pinName); + if (pin == null) return string.Empty; + + var dataConn = Blueprint.Connections.FirstOrDefault(c => c.TargetPinId == pin.Id); + if (dataConn == null) return FormatLiteralValue(pin.DefaultValue ?? string.Empty, _currentCtx); + + var sourceNode = Blueprint.GetNodeById(dataConn.SourceNodeId); + if (sourceNode is ConstNode constNode) + return constNode.ConstName; + + return dataConn.PubVarName ?? FormatLiteralValue(pin.DefaultValue ?? string.Empty, _currentCtx); + } + + /// + public string GetInputArgs(BlueprintNode node) + { + var args = new List(); + foreach (var pin in node.InputPins) + { + if (pin.Name != "Exec") + args.Add(GetInputValue(node, pin.Name)); + } + return string.Join(", ", args); + } + + /// + /// Resolves an input pin's value using the pre-built InputDataMap (for topology path). + /// + public string ResolveInputValue(BlueprintNode node, string pinName, ReverseConversionContext ctx) + { + var pin = node.InputPins.FirstOrDefault(p => p.Name == pinName); + if (pin == null) return string.Empty; + + if (ctx.InputDataMap.TryGetValue((node.Id, pin.Name), out var info)) + { + if (info.SourceNode is ConstNode constNode) + return constNode.ConstName; + if (!string.IsNullOrEmpty(info.PubVarName)) + return info.PubVarName; + } + + var defaultValue = pin.DefaultValue ?? string.Empty; + return FormatLiteralValue(defaultValue, ctx); + } + + /// + /// Finds the PubVar name assigned to a node's output pin. + /// + public static string? FindOutputPubVar(BlueprintNode node, string pinName, ReverseConversionContext ctx) + { + var pin = node.OutputPins.FirstOrDefault(p => p.Name == pinName); + if (pin == null) return null; + var conn = ctx.DataConnections.FirstOrDefault(c => c.SourcePinId == pin.Id); + return conn?.PubVarName; + } + + /// + /// Formats a literal value for BlockScript output — wraps strings in quotes, + /// passes through numbers, booleans, and already-quoted values. + /// + public static string FormatLiteralValue(string value, ReverseConversionContext? ctx = null) + { + if (string.IsNullOrEmpty(value)) return value; + if (value.StartsWith("\"")) return value; + if (ctx != null && ctx.AllPubVars.Contains(value)) return value; + if (int.TryParse(value, out _) || double.TryParse(value, out _)) return value; + if (value == "true" || value == "false") return value; + if (value.Contains("(")) return value; + if (ctx != null && ctx.Blueprint.Nodes.OfType().Any(c => c.ConstName == value)) + return value; + return $"\"{value}\""; + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ExprUtils.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ExprUtils.cs index f60c28e6..cc0fd226 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ExprUtils.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ExprUtils.cs @@ -182,4 +182,8 @@ public static bool IsStringLiteral(ExpressionSyntax expr) /// Checks if an expression is a numeric literal. public static bool IsNumericLiteral(ExpressionSyntax expr) => expr is LiteralExpressionSyntax lit && lit.Token.IsKind(SyntaxKind.NumericLiteralToken); + + /// Gets the value of any literal expression (string, int, double, bool, null). + public static object? GetLiteralValue(LiteralExpressionSyntax literal) + => literal.Token.Value; } From c9b8b536dfce129c9b77c8d531b7bbbf5d36f190 Mon Sep 17 00:00:00 2001 From: StarInk Date: Thu, 9 Apr 2026 17:51:23 +0200 Subject: [PATCH 038/110] =?UTF-8?q?=F0=9F=A7=A9=20Refactor(BlockScript):?= =?UTF-8?q?=20Introduce=20BlockScriptWellKnown=20class=20to=20centralize?= =?UTF-8?q?=20string=20constants=20for=20blocks,=20pins,=20and=20functions?= =?UTF-8?q?;=20refactor=20existing=20code=20to=20use=20these=20constants?= =?UTF-8?q?=20for=20improved=20readability=20and=20maintainability.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BlockScripting/BlockScriptWellKnown.cs | 48 +++++++++++++++++++ .../BlockScripting/BlockStatementExtractor.cs | 21 ++++---- .../BlockStructureRecognizer.cs | 22 +++++---- .../Workflow/Blueprint/ExecutionFlowWalker.cs | 38 ++++++++------- .../BranchNodeExportStrategy.cs | 7 +-- .../LoopNodeExportStrategy.cs | 7 +-- .../PrintNodeExportStrategy.cs | 7 +-- .../ExportStrategies/SetNodeExportStrategy.cs | 5 +- .../Workflow/Blueprint/INodeExportStrategy.cs | 2 +- .../Workflow/Blueprint/NodeExportHelper.cs | 4 +- .../Blueprint/Pipeline/DataEdgeBuilder.cs | 12 +++-- .../Workflow/Blueprint/Pipeline/ExprUtils.cs | 12 +++-- .../Pipeline/FormattedBlockScript.cs | 4 +- .../Blueprint/Pipeline/NodeBuilder.cs | 36 +++++++------- .../Blueprint/Pipeline/PipelineAssembler.cs | 10 ++-- .../Blueprint/Pipeline/PipelineContext.cs | 6 ++- .../Blueprint/Pipeline/ScriptFormatter.cs | 14 +++--- .../ReversePipeline/BlockScriptAssembler.cs | 10 ++-- .../ReversePipeline/ConditionDuplicator.cs | 8 ++-- 19 files changed, 180 insertions(+), 93 deletions(-) create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptWellKnown.cs diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptWellKnown.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptWellKnown.cs new file mode 100644 index 00000000..315616b3 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptWellKnown.cs @@ -0,0 +1,48 @@ +namespace KitX.Core.Workflow.BlockScripting; + +/// +/// Well-known string constants for the BlockScript/Blueprint pipeline. +/// Eliminates magic strings across parser, converter, executor, and export strategies. +/// +public static class BlockScriptWellKnown +{ + /// Block marker and block name constants + public static class Blocks + { + public const string MainBlock = "MainBlock"; + public const string ConstBlock = "ConstBlock"; + public const string PubVarBlock = "PubVarBlock"; + + // DSL markers (used in source text) + public const string MarkerConstBlock = "#ConstBlock"; + public const string MarkerPubVarBlock = "#PubVarBlock"; + public const string MarkerMainBlock = "#MainBlock"; + public const string MarkerBlockPrefix = "#Block "; + } + + /// Pin name constants + public static class Pins + { + public const string Exec = "Exec"; + public const string Condition = "Condition"; + public const string Value = "Value"; + public const string Return = "Return"; + public const string True = "True"; + public const string False = "False"; + public const string LoopBody = "LoopBody"; + public const string LoopEnd = "LoopEnd"; + } + + /// Built-in flow control and action function names + public static class Functions + { + public const string Branch = "Branch"; + public const string Loop = "Loop"; + public const string LoopBodyEnd = "LoopBodyEnd"; + public const string Break = "Break"; + public const string Print = "Print"; + public const string Pause = "Pause"; + public const string Get = "Get"; + public const string Set = "Set"; + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockStatementExtractor.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockStatementExtractor.cs index 48ca49b9..e9465608 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockStatementExtractor.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockStatementExtractor.cs @@ -7,6 +7,9 @@ using KitX.Core.Workflow.Blueprint.Pipeline; using Serilog; +using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Blocks; +using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Functions; + namespace KitX.Core.Workflow.BlockScripting; /// @@ -26,9 +29,9 @@ public BlockDefinition CreateBlockDefinition(RecognizedBlock recognized, BlockVa { blockName = recognized.BlockType switch { - BlockType.ConstBlock => "ConstBlock", - BlockType.PubVarBlock => "PubVarBlock", - BlockType.MainBlock => "MainBlock", + BlockType.ConstBlock => ConstBlock, + BlockType.PubVarBlock => PubVarBlock, + BlockType.MainBlock => MainBlock, _ => blockName }; } @@ -203,15 +206,15 @@ private void ExtractStatements(SyntaxNode root, BlockDefinition block, BlockType { var methodName = ExprUtils.GetMethodName(invoke); - if (methodName == "Branch") + if (methodName == Branch) { block.Statements.Add(CreateFlowControlStatement(invoke, FlowControlType.Branch, exprStmt.GetLineNumber(), exprText)); } - else if (methodName == "Loop") + else if (methodName == Loop) { block.Statements.Add(CreateFlowControlStatement(invoke, FlowControlType.Loop, exprStmt.GetLineNumber(), exprText)); } - else if (methodName == "LoopBodyEnd") + else if (methodName == LoopBodyEnd) { block.Statements.Add(CreateFlowControlStatement(invoke, FlowControlType.LoopBodyEnd, exprStmt.GetLineNumber(), exprText)); } @@ -233,15 +236,15 @@ private void ExtractStatements(SyntaxNode root, BlockDefinition block, BlockType var methodName = ExprUtils.GetMethodName(assignInvoke); Log.Debug("[BlockStatementExtractor] assignment.Right is InvocationExpressionSyntax, methodName = {MethodName}", methodName); - if (methodName == "Branch") + if (methodName == Branch) { block.Statements.Add(CreateFlowControlStatement(assignInvoke, FlowControlType.Branch, exprStmt.GetLineNumber(), exprText)); } - else if (methodName == "Loop") + else if (methodName == Loop) { block.Statements.Add(CreateFlowControlStatement(assignInvoke, FlowControlType.Loop, exprStmt.GetLineNumber(), exprText)); } - else if (methodName == "LoopBodyEnd") + else if (methodName == LoopBodyEnd) { block.Statements.Add(CreateFlowControlStatement(assignInvoke, FlowControlType.LoopBodyEnd, exprStmt.GetLineNumber(), exprText)); } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockStructureRecognizer.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockStructureRecognizer.cs index 33b5b8c5..18ecf1e4 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockStructureRecognizer.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockStructureRecognizer.cs @@ -4,6 +4,8 @@ using KitX.Core.Contract.Workflow; using Serilog; +using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Blocks; + namespace KitX.Core.Workflow.BlockScripting; /// @@ -72,7 +74,7 @@ public void RecognizeBlocks() var blockName = string.Empty; if (blockType == BlockType.NamedBlock) { - blockName = trimmed.Substring("#Block ".Length).Trim(); + blockName = trimmed.Substring(MarkerBlockPrefix.Length).Trim(); } // Start new block @@ -122,21 +124,21 @@ private int GetLineStartPosition(int zeroBasedLineIndex) private BlockType? TryParseBlockMarker(string trimmed) { // #ConstBlock - if (trimmed == "#ConstBlock") + if (trimmed == MarkerConstBlock) return BlockType.ConstBlock; // #PubVarBlock - if (trimmed == "#PubVarBlock") + if (trimmed == MarkerPubVarBlock) return BlockType.PubVarBlock; // #MainBlock - if (trimmed == "#MainBlock") + if (trimmed == MarkerMainBlock) return BlockType.MainBlock; // #Block Name - if (trimmed.StartsWith("#Block ")) + if (trimmed.StartsWith(MarkerBlockPrefix)) { - var name = trimmed.Substring("#Block ".Length).Trim(); + var name = trimmed.Substring(MarkerBlockPrefix.Length).Trim(); if (!string.IsNullOrEmpty(name)) return BlockType.NamedBlock; } @@ -161,10 +163,10 @@ private string ExtractPureCode(int start, int end) var trimmed = line.Trim(); // Skip block marker lines - if (trimmed == "#ConstBlock" || - trimmed == "#PubVarBlock" || - trimmed == "#MainBlock" || - trimmed.StartsWith("#Block ")) + if (trimmed == MarkerConstBlock || + trimmed == MarkerPubVarBlock || + trimmed == MarkerMainBlock || + trimmed.StartsWith(MarkerBlockPrefix)) { continue; } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExecutionFlowWalker.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExecutionFlowWalker.cs index 9bc89d7c..5d356c7b 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExecutionFlowWalker.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExecutionFlowWalker.cs @@ -1,9 +1,13 @@ using System.Collections.Generic; using System.Linq; using KitX.Core.Contract.Workflow; +using KitX.Core.Workflow.BlockScripting; using KitX.Core.Workflow.Blueprint.ReversePipeline; using Serilog; +using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Pins; +using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Blocks; + namespace KitX.Core.Workflow.Blueprint; /// @@ -37,7 +41,7 @@ public void WalkExecutionFlow(ReverseConversionContext ctx) return; } - var mainBlock = new BlockDefinition { Type = BlockType.MainBlock, Name = "MainBlock" }; + var mainBlock = new BlockDefinition { Type = BlockType.MainBlock, Name = BlockScriptWellKnown.Blocks.MainBlock }; ctx.Script.MainBlock = mainBlock; var visited = new HashSet(); @@ -63,7 +67,7 @@ public void WalkFromBlockScopes(Contract.Workflow.Blueprint blueprint, ReverseCo BlockDefinition block; if (scope.IsMainBlock) { - block = new BlockDefinition { Type = BlockType.MainBlock, Name = "MainBlock" }; + block = new BlockDefinition { Type = BlockType.MainBlock, Name = BlockScriptWellKnown.Blocks.MainBlock }; ctx.Script.MainBlock = block; } else @@ -94,9 +98,9 @@ public void WalkFromBlockScopes(Contract.Workflow.Blueprint blueprint, ReverseCo { if (scope.OwnerNodeId == null) continue; if (!ctx.ControlFlowMap.TryGetValue(scope.OwnerNodeId, out var flow)) continue; - if (scope.OwnerArmName == "True" || scope.OwnerArmName == "LoopBody") + if (scope.OwnerArmName == BlockScriptWellKnown.Pins.True || scope.OwnerArmName == BlockScriptWellKnown.Pins.LoopBody) flow.TrueBlockName = scope.Name; - else if (scope.OwnerArmName == "False" || scope.OwnerArmName == "LoopEnd") + else if (scope.OwnerArmName == BlockScriptWellKnown.Pins.False || scope.OwnerArmName == BlockScriptWellKnown.Pins.LoopEnd) flow.FalseBlockName = scope.Name; } @@ -138,7 +142,7 @@ private void WalkNode(BlueprintNode node, BlockDefinition currentBlock, return; } - var execOut = node.OutputPins.FirstOrDefault(p => p.Name == "Exec"); + var execOut = node.OutputPins.FirstOrDefault(p => p.Name == Exec); if (execOut == null) { if (loopbackTargetId != null) @@ -216,11 +220,11 @@ private static FlowControlStatement CreateLoopBodyEndStatement(string? returnTo if (node.NodeType is BlueprintNodeType.Call or BlueprintNodeType.CallHelper && stmt is ExpressionStatement exprStmt) { - var returnPin = node.OutputPins.FirstOrDefault(p => p.Name == "Return"); - bool hasReturn = returnPin != null && ctx.ConsumedOutputs.Contains((node.Id, "Return")); + var returnPin = node.OutputPins.FirstOrDefault(p => p.Name == Return); + bool hasReturn = returnPin != null && ctx.ConsumedOutputs.Contains((node.Id, Return)); if (hasReturn) { - var pubVar = NodeExportHelper.FindOutputPubVar(node, "Return", ctx); + var pubVar = NodeExportHelper.FindOutputPubVar(node, Return, ctx); if (pubVar != null) exprStmt.SourceCode = $"{pubVar} = {exprStmt.Expression};"; } @@ -231,10 +235,10 @@ private static FlowControlStatement CreateLoopBodyEndStatement(string? returnTo private BlockStatement? GenerateGetStatementViaStrategy(BlueprintNode node, ReverseConversionContext ctx) { - if (!ctx.ConsumedOutputs.Contains((node.Id, "Value"))) + if (!ctx.ConsumedOutputs.Contains((node.Id, Value))) return null; - var pubVar = NodeExportHelper.FindOutputPubVar(node, "Value", ctx); + var pubVar = NodeExportHelper.FindOutputPubVar(node, Value, ctx); if (pubVar == null) return null; if (!_strategies.TryGetValue(BlueprintNodeType.Get, out var strategy)) @@ -297,7 +301,7 @@ private void ProcessBranchSubGraphs(BlueprintNode branchNode, ReverseConversionC var falseBlockName = string.Empty; var currentLoopback = ctx.CurrentLoopbackTargetId; - foreach (var pinName in new[] { "True", "False" }) + foreach (var pinName in new[] { True, False }) { var pin = branchNode.OutputPins.FirstOrDefault(p => p.Name == pinName); if (pin == null) continue; @@ -316,7 +320,7 @@ private void ProcessBranchSubGraphs(BlueprintNode branchNode, ReverseConversionC ctx.Script.NamedBlocks[blockName] = block; processedTargets.Add(conn.TargetNodeId); - if (pinName == "True") trueBlockName = blockName; + if (pinName == True) trueBlockName = blockName; else falseBlockName = blockName; } @@ -329,7 +333,7 @@ private void ProcessLoopSubGraphs(BlueprintNode loopNode, ReverseConversionConte var loopBodyBlockName = string.Empty; var loopEndBlockName = string.Empty; - var loopBodyPin = loopNode.OutputPins.FirstOrDefault(p => p.Name == "LoopBody"); + var loopBodyPin = loopNode.OutputPins.FirstOrDefault(p => p.Name == LoopBody); if (loopBodyPin != null) { var conn = ctx.ExecConnections.FirstOrDefault(c => c.SourcePinId == loopBodyPin.Id); @@ -351,7 +355,7 @@ private void ProcessLoopSubGraphs(BlueprintNode loopNode, ReverseConversionConte } } - var loopEndPin = loopNode.OutputPins.FirstOrDefault(p => p.Name == "LoopEnd"); + var loopEndPin = loopNode.OutputPins.FirstOrDefault(p => p.Name == LoopEnd); if (loopEndPin != null) { var conn = ctx.ExecConnections.FirstOrDefault(c => c.SourcePinId == loopEndPin.Id); @@ -420,7 +424,7 @@ private static (BlueprintNode? loopNode, List path) FollowExecCha if (current.NodeType == BlueprintNodeType.Loop) return (current, path); - var execOutPin = current.OutputPins.FirstOrDefault(p => p.Name == "Exec"); + var execOutPin = current.OutputPins.FirstOrDefault(p => p.Name == Exec); if (execOutPin == null) break; var execConn = ctx.ExecConnections.FirstOrDefault(c => c.SourcePinId == execOutPin.Id); @@ -438,7 +442,7 @@ private static (BlueprintNode? loopNode, List path) FollowExecCha private void GenerateConditionStatements(BlueprintNode loopNode, BlockDefinition blockDef, ReverseConversionContext ctx) { - var condPin = loopNode.InputPins.FirstOrDefault(p => p.Name == "Condition"); + var condPin = loopNode.InputPins.FirstOrDefault(p => p.Name == Condition); if (condPin == null) return; var condConn = ctx.DataConnections.FirstOrDefault(c => c.TargetPinId == condPin.Id); @@ -456,7 +460,7 @@ private void GenerateDataChainStatements(string sourceNodeId, foreach (var inputPin in sourceNode.InputPins) { - if (inputPin.Name == "Exec") continue; + if (inputPin.Name == Exec) continue; var upConn = ctx.DataConnections.FirstOrDefault(c => c.TargetPinId == inputPin.Id); if (upConn != null) GenerateDataChainStatements(upConn.SourceNodeId, blockDef, ctx, generated); diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/BranchNodeExportStrategy.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/BranchNodeExportStrategy.cs index fa99fe29..753c25ef 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/BranchNodeExportStrategy.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/BranchNodeExportStrategy.cs @@ -1,4 +1,5 @@ using KitX.Core.Contract.Workflow; +using KitX.Core.Workflow.BlockScripting; namespace KitX.Core.Workflow.Blueprint.ExportStrategies; @@ -9,7 +10,7 @@ public class BranchNodeExportStrategy : INodeExportStrategy public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) { - var condition = helper.GetInputValue(node, "Condition"); + var condition = helper.GetInputValue(node, BlockScriptWellKnown.Pins.Condition); return new FlowControlStatement { ControlType = FlowControlType.Branch, @@ -23,8 +24,8 @@ public IEnumerable GetOutputArms(BlueprintNode node) { return [ - new OutputArmDescriptor { PinName = "True", IsLoopback = false }, - new OutputArmDescriptor { PinName = "False", IsLoopback = false } + new OutputArmDescriptor { PinName = BlockScriptWellKnown.Pins.True, IsLoopback = false }, + new OutputArmDescriptor { PinName = BlockScriptWellKnown.Pins.False, IsLoopback = false } ]; } } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/LoopNodeExportStrategy.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/LoopNodeExportStrategy.cs index e1285eff..e8747959 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/LoopNodeExportStrategy.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/LoopNodeExportStrategy.cs @@ -1,4 +1,5 @@ using KitX.Core.Contract.Workflow; +using KitX.Core.Workflow.BlockScripting; namespace KitX.Core.Workflow.Blueprint.ExportStrategies; @@ -9,7 +10,7 @@ public class LoopNodeExportStrategy : INodeExportStrategy public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) { - var condition = helper.GetInputValue(node, "Condition"); + var condition = helper.GetInputValue(node, BlockScriptWellKnown.Pins.Condition); return new FlowControlStatement { ControlType = FlowControlType.Loop, @@ -23,8 +24,8 @@ public IEnumerable GetOutputArms(BlueprintNode node) { return [ - new OutputArmDescriptor { PinName = "LoopBody", IsLoopback = true }, - new OutputArmDescriptor { PinName = "LoopEnd", IsLoopback = false } + new OutputArmDescriptor { PinName = BlockScriptWellKnown.Pins.LoopBody, IsLoopback = true }, + new OutputArmDescriptor { PinName = BlockScriptWellKnown.Pins.LoopEnd, IsLoopback = false } ]; } } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/PrintNodeExportStrategy.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/PrintNodeExportStrategy.cs index 84c48980..cec94eff 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/PrintNodeExportStrategy.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/PrintNodeExportStrategy.cs @@ -1,4 +1,5 @@ using KitX.Core.Contract.Workflow; +using KitX.Core.Workflow.BlockScripting; namespace KitX.Core.Workflow.Blueprint.ExportStrategies; @@ -9,11 +10,11 @@ public class PrintNodeExportStrategy : INodeExportStrategy public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) { - var value = helper.GetInputValue(node, "Value"); + var value = helper.GetInputValue(node, BlockScriptWellKnown.Pins.Value); return new ExpressionStatement { - Expression = $"Print({value});", - SourceCode = $"Print({value});", + Expression = $"{BlockScriptWellKnown.Functions.Print}({value});", + SourceCode = $"{BlockScriptWellKnown.Functions.Print}({value});", LineNumber = 1 }; } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/SetNodeExportStrategy.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/SetNodeExportStrategy.cs index 641cbb96..4c8832cf 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/SetNodeExportStrategy.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/SetNodeExportStrategy.cs @@ -1,4 +1,5 @@ using KitX.Core.Contract.Workflow; +using KitX.Core.Workflow.BlockScripting; namespace KitX.Core.Workflow.Blueprint.ExportStrategies; @@ -10,8 +11,8 @@ public class SetNodeExportStrategy : INodeExportStrategy public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) { if (node is not SetNode setNode) return null; - var value = helper.GetInputValue(node, "Value"); - var sourceCode = $"Set(\"{setNode.VarName}\", {value})"; + var value = helper.GetInputValue(node, BlockScriptWellKnown.Pins.Value); + var sourceCode = $"{BlockScriptWellKnown.Functions.Set}(\"{setNode.VarName}\", {value})"; return new ExpressionStatement { Expression = sourceCode, diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/INodeExportStrategy.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/INodeExportStrategy.cs index 7436704b..7e9b628e 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/INodeExportStrategy.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/INodeExportStrategy.cs @@ -64,7 +64,7 @@ public interface INodeExportStrategy public struct OutputArmDescriptor { /// - /// The output pin name (e.g., "True", "False", "LoopBody", "LoopEnd") + /// The output pin name (e.g., Pins.True, Pins.False, Pins.LoopBody, Pins.LoopEnd) /// public string PinName { get; set; } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeExportHelper.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeExportHelper.cs index 0620acf7..8802a2de 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeExportHelper.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeExportHelper.cs @@ -3,6 +3,8 @@ using KitX.Core.Contract.Workflow; using KitX.Core.Workflow.Blueprint.ReversePipeline; +using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Pins; + namespace KitX.Core.Workflow.Blueprint; /// @@ -46,7 +48,7 @@ public string GetInputArgs(BlueprintNode node) var args = new List(); foreach (var pin in node.InputPins) { - if (pin.Name != "Exec") + if (pin.Name != Exec) args.Add(GetInputValue(node, pin.Name)); } return string.Join(", ", args); diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/DataEdgeBuilder.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/DataEdgeBuilder.cs index a7220917..24c87913 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/DataEdgeBuilder.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/DataEdgeBuilder.cs @@ -2,6 +2,8 @@ using System.Collections.Generic; using System.Linq; using KitX.Core.Contract.Workflow; + +using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Pins; using Serilog; namespace KitX.Core.Workflow.Blueprint.Pipeline; @@ -44,7 +46,7 @@ private void ProcessStatement(FormattedStatement stmt, PipelineContext context) break; case FormattedStatementKind.Print: - ProcessSingleValueInput(stmt, stmt.Arguments, "Value", context); + ProcessSingleValueInput(stmt, stmt.Arguments, Value, context); break; case FormattedStatementKind.Set: @@ -120,7 +122,7 @@ private void ProcessArgument(string arg, BlueprintNode targetNode, string target context.DataEdges.Add(new PendingDataEdge { SourceNodeId = constNode.Id, - SourcePinName = "Value", + SourcePinName = Value, TargetNodeId = targetNode.Id, TargetPinName = targetPinName }); @@ -153,7 +155,7 @@ private void ProcessSetValue(FormattedStatement stmt, PipelineContext context) // Set's Value pin receives the second argument (first was extracted as SetVarName) if (stmt.Arguments.Count > 0) { - ProcessArgument(stmt.Arguments[0], targetNode, "Value", stmt, 0, context); + ProcessArgument(stmt.Arguments[0], targetNode, Value, stmt, 0, context); } } @@ -163,7 +165,7 @@ private void ProcessConditionInput(FormattedStatement stmt, PipelineContext cont if (!context.NodeByStatementId.TryGetValue(stmt.StatementId, out var targetNode)) return; // Condition PubVar → find the PubVarAssignment source and connect to Condition pin - ConnectPubVarSource(stmt.ConditionPubVar, targetNode, "Condition", context); + ConnectPubVarSource(stmt.ConditionPubVar, targetNode, Condition, context); } // ────────────────────────────────────────────── @@ -232,7 +234,7 @@ private static void SetDefaultValue(BlueprintNode node, string pinName, string v private static string? GetParamPinName(BlueprintNode node, int argIndex) { // Find parameter pins (skip Exec pin at index 0) - var paramPins = node.InputPins.Where(p => p.Name != "Exec").ToList(); + var paramPins = node.InputPins.Where(p => p.Name != Exec).ToList(); if (argIndex < paramPins.Count) return paramPins[argIndex].Name; return null; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ExprUtils.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ExprUtils.cs index cc0fd226..2efa3a42 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ExprUtils.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ExprUtils.cs @@ -7,6 +7,7 @@ using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; +using KitX.Core.Workflow.BlockScripting; namespace KitX.Core.Workflow.Blueprint.Pipeline; @@ -17,17 +18,22 @@ public static class ExprUtils { public static readonly HashSet BuiltinFunctions = new() { - "Get", "Set", "Print", "Pause", "Branch", "Loop", "LoopBodyEnd", "Break" + BlockScriptWellKnown.Functions.Get, BlockScriptWellKnown.Functions.Set, + BlockScriptWellKnown.Functions.Print, BlockScriptWellKnown.Functions.Pause, + BlockScriptWellKnown.Functions.Branch, BlockScriptWellKnown.Functions.Loop, + BlockScriptWellKnown.Functions.LoopBodyEnd, BlockScriptWellKnown.Functions.Break }; public static readonly HashSet NonExtractableFunctions = new() { - "Set", "Print", "Pause" + BlockScriptWellKnown.Functions.Set, BlockScriptWellKnown.Functions.Print, + BlockScriptWellKnown.Functions.Pause }; public static readonly HashSet FlowControlFunctions = new() { - "Branch", "Loop", "LoopBodyEnd", "Break" + BlockScriptWellKnown.Functions.Branch, BlockScriptWellKnown.Functions.Loop, + BlockScriptWellKnown.Functions.LoopBodyEnd, BlockScriptWellKnown.Functions.Break }; /// Parses an expression string using Roslyn. Returns null on failure. diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/FormattedBlockScript.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/FormattedBlockScript.cs index f1c039fd..65b7b122 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/FormattedBlockScript.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/FormattedBlockScript.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; +using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Blocks; + namespace KitX.Core.Workflow.Blueprint.Pipeline; /// @@ -17,7 +19,7 @@ public class FormattedBlockScript /// /// Main block name (always first in Blocks list). /// - public string MainBlockName { get; set; } = "MainBlock"; + public string MainBlockName { get; set; } = MainBlock; } /// diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/NodeBuilder.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/NodeBuilder.cs index 00f3aa7d..1f65f59e 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/NodeBuilder.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/NodeBuilder.cs @@ -2,8 +2,12 @@ using System.Collections.Generic; using System.Linq; using KitX.Core.Contract.Workflow; +using KitX.Core.Workflow.BlockScripting; using Serilog; +using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Pins; +using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Functions; + namespace KitX.Core.Workflow.Blueprint.Pipeline; /// @@ -170,7 +174,7 @@ or FormattedStatementKind.LoopBodyEnd // For non-Get statements: reuse by fingerprint PubVarAssignment? existing = null; - if (stmt.FunctionName == "Get") + if (stmt.FunctionName == Get) { // Get nodes: reuse when PubVarTarget matches and reads the same variable. // This handles both LOOP_COND_DUP and round-trip re-parsed Get statements @@ -203,7 +207,7 @@ or FormattedStatementKind.LoopBodyEnd // --- Create new node --- BlueprintNode mainNode; - if (stmt.FunctionName == "Get") + if (stmt.FunctionName == Get) { // Get assignment → create GetNode var varName = stmt.Arguments?.Count > 0 ? stmt.Arguments[0].Trim('"') : ""; @@ -241,11 +245,11 @@ or FormattedStatementKind.LoopBodyEnd // Register PubVar assignment for reuse if (!string.IsNullOrEmpty(stmt.PubVarTarget)) { - var outputPinName = stmt.FunctionName == "Get" ? "Value" : "Return"; + var outputPinName = stmt.FunctionName == Get ? Value : Return; var outputPin = mainNode.OutputPins.First(p => p.Name == outputPinName); // Get: key by PubVarTarget (each Get is unique, identified by its PubVar) // Others: key by fingerprint (for reuse detection) - var key = stmt.FunctionName == "Get" ? stmt.PubVarTarget : (stmt.Fingerprint ?? stmt.PubVarTarget); + var key = stmt.FunctionName == Get ? stmt.PubVarTarget : (stmt.Fingerprint ?? stmt.PubVarTarget); context.PubVarAssignments[key] = new PubVarAssignment { PubVarName = stmt.PubVarTarget, @@ -275,8 +279,8 @@ private void ResolveCrossBlockEdges(PipelineContext context) { SourceStatementId = stmtId, TargetStatementId = targetStmtId, - SourcePinName = "True", - TargetPinName = "Exec", + SourcePinName = True, + TargetPinName = Exec, IsSpecialRouting = true }); } @@ -289,8 +293,8 @@ private void ResolveCrossBlockEdges(PipelineContext context) { SourceStatementId = stmtId, TargetStatementId = targetStmtId, - SourcePinName = "False", - TargetPinName = "Exec", + SourcePinName = False, + TargetPinName = Exec, IsSpecialRouting = true }); } @@ -307,8 +311,8 @@ private void ResolveCrossBlockEdges(PipelineContext context) { SourceStatementId = stmtId, TargetStatementId = targetStmtId, - SourcePinName = "LoopBody", - TargetPinName = "Exec", + SourcePinName = LoopBody, + TargetPinName = Exec, IsSpecialRouting = true }); } @@ -321,8 +325,8 @@ private void ResolveCrossBlockEdges(PipelineContext context) { SourceStatementId = stmtId, TargetStatementId = targetStmtId, - SourcePinName = "LoopEnd", - TargetPinName = "Exec", + SourcePinName = LoopEnd, + TargetPinName = Exec, IsSpecialRouting = true }); } @@ -339,8 +343,8 @@ private void ResolveCrossBlockEdges(PipelineContext context) { SourceStatementId = prevStmtId, TargetStatementId = loopStmtId, - SourcePinName = "Exec", - TargetPinName = "Exec" + SourcePinName = Exec, + TargetPinName = Exec }); } @@ -359,8 +363,8 @@ private void ResolveCrossBlockEdges(PipelineContext context) { SourceStatementId = lastStmtId, TargetStatementId = targetStmtId, - SourcePinName = "Exec", - TargetPinName = "Exec" + SourcePinName = Exec, + TargetPinName = Exec }); } } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineAssembler.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineAssembler.cs index eb96ccba..cf528558 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineAssembler.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineAssembler.cs @@ -4,6 +4,8 @@ using KitX.Core.Contract.Workflow; using Serilog; +using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Pins; + namespace KitX.Core.Workflow.Blueprint.Pipeline; /// @@ -243,12 +245,12 @@ private void BuildBlockScopes(Contract.Workflow.Blueprint bp, PipelineContext ct if (trueBlock != null && scopesByName.TryGetValue(trueBlock, out var trueScope)) { trueScope.OwnerNodeId = branchNode.Id; - trueScope.OwnerArmName = "True"; + trueScope.OwnerArmName = True; } if (falseBlock != null && scopesByName.TryGetValue(falseBlock, out var falseScope)) { falseScope.OwnerNodeId = branchNode.Id; - falseScope.OwnerArmName = "False"; + falseScope.OwnerArmName = False; } } @@ -260,12 +262,12 @@ private void BuildBlockScopes(Contract.Workflow.Blueprint bp, PipelineContext ct if (loopBody != null && scopesByName.TryGetValue(loopBody, out var bodyScope)) { bodyScope.OwnerNodeId = loopNode.Id; - bodyScope.OwnerArmName = "LoopBody"; + bodyScope.OwnerArmName = LoopBody; } if (loopEnd != null && scopesByName.TryGetValue(loopEnd, out var endScope)) { endScope.OwnerNodeId = loopNode.Id; - endScope.OwnerArmName = "LoopEnd"; + endScope.OwnerArmName = LoopEnd; } } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineContext.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineContext.cs index eb8ff192..244cc788 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineContext.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineContext.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; + +using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Pins; using KitX.Core.Contract.Workflow; namespace KitX.Core.Workflow.Blueprint.Pipeline; @@ -125,8 +127,8 @@ public class PendingExecEdge { public string SourceStatementId { get; set; } = string.Empty; public string TargetStatementId { get; set; } = string.Empty; - public string SourcePinName { get; set; } = "Exec"; - public string TargetPinName { get; set; } = "Exec"; + public string SourcePinName { get; set; } = Exec; + public string TargetPinName { get; set; } = Exec; // For special routing (Branch.True, Branch.False, Loop.LoopBody, Loop.LoopEnd) public bool IsSpecialRouting { get; set; } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ScriptFormatter.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ScriptFormatter.cs index 30e90335..2a5b82af 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ScriptFormatter.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ScriptFormatter.cs @@ -6,6 +6,8 @@ using KitX.Core.Contract.Workflow; using Serilog; +using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Functions; + namespace KitX.Core.Workflow.Blueprint.Pipeline; /// @@ -241,13 +243,13 @@ private List FormatInvocation( switch (funcName) { - case "Print": + case Print: kind = FormattedStatementKind.Print; break; - case "Pause": + case Pause: kind = FormattedStatementKind.Pause; break; - case "Set": + case Set: kind = FormattedStatementKind.Set; // First arg is varName (string literal) if (currentArgExprs.Count > 0) @@ -258,7 +260,7 @@ private List FormatInvocation( currentArgExprs.RemoveAt(0); } break; - case "Get": + case Get: kind = FormattedStatementKind.Assignment; if (currentArgExprs.Count > 0) { @@ -370,7 +372,7 @@ private List FormatInvocation( // cloned Get statements (from Loop condition duplication) must have // GetVarName set so the reuse check (PubVarTarget + GetVarName) can // match them to the original Get node instead of creating duplicates. - if (funcName == "Get") + if (funcName == Get) { var varName = invoke.ArgumentList.Arguments.Count > 0 ? ExprUtils.GetStringLiteralValue(invoke.ArgumentList.Arguments[0].Expression) @@ -387,7 +389,7 @@ private List FormatInvocation( { BlockName = blockName, Kind = FormattedStatementKind.Assignment, - FunctionName = "Get", + FunctionName = Get, PubVarTarget = getPubVar, GetVarName = varName, Arguments = new List { $"\"{varName}\"" }, diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ReversePipeline/BlockScriptAssembler.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ReversePipeline/BlockScriptAssembler.cs index b95a4fa0..cc9a0059 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ReversePipeline/BlockScriptAssembler.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ReversePipeline/BlockScriptAssembler.cs @@ -3,6 +3,8 @@ using KitX.Core.Contract.Workflow; using Serilog; +using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Blocks; + namespace KitX.Core.Workflow.Blueprint.ReversePipeline; /// @@ -19,7 +21,7 @@ public void Assemble(ReverseConversionContext ctx) var constNodes = ctx.Blueprint.Nodes.OfType().ToList(); if (constNodes.Count > 0) { - sb.AppendLine("#ConstBlock"); + sb.AppendLine(MarkerConstBlock); foreach (var cn in constNodes) { if (string.IsNullOrEmpty(cn.ConstValue)) @@ -38,7 +40,7 @@ public void Assemble(ReverseConversionContext ctx) // #PubVarBlock — use 'dynamic' for late-bound type resolution if (ctx.AllPubVars.Count > 0) { - sb.AppendLine("#PubVarBlock"); + sb.AppendLine(MarkerPubVarBlock); foreach (var pv in ctx.AllPubVars) { sb.AppendLine($"dynamic {pv};"); @@ -49,7 +51,7 @@ public void Assemble(ReverseConversionContext ctx) // #MainBlock if (ctx.Script.MainBlock != null) { - sb.AppendLine("#MainBlock"); + sb.AppendLine(MarkerMainBlock); foreach (var stmt in ctx.Script.MainBlock.Statements) { sb.AppendLine(stmt.SourceCode); @@ -60,7 +62,7 @@ public void Assemble(ReverseConversionContext ctx) // Named blocks foreach (var kvp in ctx.Script.NamedBlocks) { - sb.AppendLine($"#Block {kvp.Key}"); + sb.AppendLine($"{MarkerBlockPrefix}{kvp.Key}"); foreach (var stmt in kvp.Value.Statements) { sb.AppendLine(stmt.SourceCode); diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ReversePipeline/ConditionDuplicator.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ReversePipeline/ConditionDuplicator.cs index accb3e55..1a0dd5f5 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ReversePipeline/ConditionDuplicator.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ReversePipeline/ConditionDuplicator.cs @@ -2,6 +2,8 @@ using System.Linq; using KitX.Core.Contract.Workflow; +using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Pins; + namespace KitX.Core.Workflow.Blueprint.ReversePipeline; /// @@ -57,10 +59,10 @@ private List FindConditionStatements(BlueprintNode loopNode { var result = new List(); - var conditionPin = loopNode.InputPins.FirstOrDefault(p => p.Name == "Condition"); + var conditionPin = loopNode.InputPins.FirstOrDefault(p => p.Name == Condition); if (conditionPin == null) return result; - if (!ctx.InputDataMap.TryGetValue((loopNode.Id, "Condition"), out var condInfo)) + if (!ctx.InputDataMap.TryGetValue((loopNode.Id, Condition), out var condInfo)) return result; var chainPubVars = new HashSet(); @@ -108,7 +110,7 @@ private static void CollectConditionChain(DataEdgeInfo info, ReverseConversionCo { foreach (var pin in info.SourceNode.InputPins) { - if (pin.Name == "Exec") continue; + if (pin.Name == Exec) continue; if (ctx.InputDataMap.TryGetValue((info.SourceNode.Id, pin.Name), out var argInfo)) { CollectConditionChain(argInfo, ctx, pubVars); From 52e7885c61fdfc80fbe6b00f1f3f755e926ffb8c Mon Sep 17 00:00:00 2001 From: StarInk Date: Thu, 9 Apr 2026 19:40:57 +0200 Subject: [PATCH 039/110] =?UTF-8?q?=F0=9F=92=BE=20Feat(Blueprint):=20?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E5=8F=98=E9=87=8F=E8=8A=82=E7=82=B9=E8=BD=AC?= =?UTF-8?q?=E6=8D=A2=E7=AE=A1=E7=BA=BF=E4=B8=8E=E5=B8=B8=E9=87=8F=E5=80=BC?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E6=97=B6=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Phase1 区分有/无初始值变量,分别生成 ConstNode 和 VariableNode - PipelineContext 新增 VariableNodes 字典,NodeRegistry 注册 VariableNode - DataEdgeBuilder 和 ScriptFormatter 支持 VariableNode 引用识别 - PipelineAssembler 合并 VariableNode 到 ConstValues - BlockScriptAssembler 反向输出 VariableNode 声明 - WorkflowScriptService 实现 constantOverrides 重载,执行前覆盖 DefaultValue --- .../BlockScriptToBlueprintConverter.cs | 31 ++++++++++++++----- .../Workflow/Blueprint/NodeRegistry.cs | 1 + .../Blueprint/Pipeline/DataEdgeBuilder.cs | 9 ++++++ .../Blueprint/Pipeline/PipelineAssembler.cs | 13 +++++++- .../Blueprint/Pipeline/PipelineContext.cs | 1 + .../Blueprint/Pipeline/ScriptFormatter.cs | 4 +-- .../ReversePipeline/BlockScriptAssembler.cs | 9 +++++- .../Workflow/WorkflowScriptService.cs | 28 +++++++++++++++-- KitX Clients/KitX Dashboard | 2 +- KitX Standard | 2 +- 10 files changed, 84 insertions(+), 16 deletions(-) diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlockScriptToBlueprintConverter.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlockScriptToBlueprintConverter.cs index 1b0f1ee4..e2fc91e3 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlockScriptToBlueprintConverter.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlockScriptToBlueprintConverter.cs @@ -98,18 +98,33 @@ public Contract.Workflow.Blueprint Convert(BlockScript script) private void Phase1_ProcessConstAndPubVar(PipelineContext context) { - // Process ConstBlock variables → ConstNodes + // Process ConstBlock variables → ConstNodes or VariableNodes if (context.Script.ConstBlock != null) { foreach (var varDecl in context.Script.ConstBlock.Variables) { - var value = varDecl.DefaultValue?.ToString() ?? varDecl.InitialValueExpression ?? ""; - var constNode = (ConstNode)_nodeRegistry.Create(BlueprintNodeType.Const); - constNode.ConstName = varDecl.Name; - constNode.ConstType = varDecl.Type; - constNode.ConstValue = value; - context.ConstNodes[varDecl.Name] = constNode; - context.AllNodes.Add(constNode); + var hasInitialValue = varDecl.DefaultValue != null || !string.IsNullOrEmpty(varDecl.InitialValueExpression); + + if (hasInitialValue) + { + // Variable with initial value → ConstNode (editable value) + var value = varDecl.DefaultValue?.ToString() ?? varDecl.InitialValueExpression ?? ""; + var constNode = (ConstNode)_nodeRegistry.Create(BlueprintNodeType.Const); + constNode.ConstName = varDecl.Name; + constNode.ConstType = varDecl.Type; + constNode.ConstValue = value; + context.ConstNodes[varDecl.Name] = constNode; + context.AllNodes.Add(constNode); + } + else + { + // Variable without initial value → VariableNode (type-only, floating) + var varNode = (VariableNode)_nodeRegistry.Create(BlueprintNodeType.Variable); + varNode.VarName = varDecl.Name; + varNode.VarType = varDecl.Type; + context.VariableNodes[varDecl.Name] = varNode; + context.AllNodes.Add(varNode); + } } } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeRegistry.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeRegistry.cs index 87a41b14..9caf2e1b 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeRegistry.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeRegistry.cs @@ -31,6 +31,7 @@ public NodeRegistry() [BlueprintNodeType.Set] = typeof(SetNode), [BlueprintNodeType.Print] = typeof(PrintNode), [BlueprintNodeType.Pause] = typeof(PauseNode), + [BlueprintNodeType.Variable] = typeof(VariableNode), }; // Pre-cache descriptors from each node type diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/DataEdgeBuilder.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/DataEdgeBuilder.cs index 24c87913..8531b4ba 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/DataEdgeBuilder.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/DataEdgeBuilder.cs @@ -129,6 +129,15 @@ private void ProcessArgument(string arg, BlueprintNode targetNode, string target return; } + // VariableNode (no initial value) → set as DefaultValue fallback on target pin + // VariableNodes have no output ports, so we can't create a data edge. + // The variable reference is resolved at runtime. + if (context.VariableNodes.ContainsKey(trimmed)) + { + SetDefaultValue(targetNode, targetPinName, trimmed); + return; + } + // Unknown identifier → try as variable reference (set DefaultValue as fallback) Log.Warning("[DataEdgeBuilder] Unresolved argument: {Arg} in stmt {StmtId}", trimmed, parentStmt.StatementId); SetDefaultValue(targetNode, targetPinName, trimmed); diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineAssembler.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineAssembler.cs index cf528558..60768d66 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineAssembler.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineAssembler.cs @@ -28,7 +28,7 @@ public Contract.Workflow.Blueprint Assemble(PipelineContext context) HelperFunctions = context.HelperFunctions, }; - // Add ConstValues + // Add ConstValues (from ConstNodes with initial values) foreach (var kvp in context.ConstNodes) { bp.ConstValues.Add(new VariableConstant @@ -39,6 +39,17 @@ public Contract.Workflow.Blueprint Assemble(PipelineContext context) }); } + // Add VariableNodes to ConstValues (no initial value, type-only) + foreach (var kvp in context.VariableNodes) + { + bp.ConstValues.Add(new VariableConstant + { + Name = kvp.Value.VarName, + DefaultValue = null, + Type = kvp.Value.VarType ?? "int" + }); + } + // Add all nodes + build lookup foreach (var node in context.AllNodes) { diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineContext.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineContext.cs index 244cc788..23b7c0d5 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineContext.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineContext.cs @@ -18,6 +18,7 @@ public class PipelineContext // --- Phase 1 output --- public Dictionary ConstNodes { get; set; } = new(); + public Dictionary VariableNodes { get; set; } = new(); public List PubVarNames { get; set; } = new(); // --- Phase 2 output --- diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ScriptFormatter.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ScriptFormatter.cs index 2a5b82af..2cb6f878 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ScriptFormatter.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ScriptFormatter.cs @@ -457,8 +457,8 @@ private List FormatInvocation( if (context.PubVarNames.Contains(trimmed)) return (result, trimmed); - // ConstBlock variable - if (context.ConstNodes.ContainsKey(trimmed)) + // ConstBlock variable or VariableNode + if (context.ConstNodes.ContainsKey(trimmed) || context.VariableNodes.ContainsKey(trimmed)) return (result, null); // Parse the expression diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ReversePipeline/BlockScriptAssembler.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ReversePipeline/BlockScriptAssembler.cs index cc9a0059..b4080c56 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ReversePipeline/BlockScriptAssembler.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ReversePipeline/BlockScriptAssembler.cs @@ -18,8 +18,11 @@ public void Assemble(ReverseConversionContext ctx) var sb = new StringBuilder(); // #ConstBlock — no 'const' keyword in BlockScript ConstBlock + // Includes both ConstNode (with initial values) and VariableNode (without initial values) var constNodes = ctx.Blueprint.Nodes.OfType().ToList(); - if (constNodes.Count > 0) + var varNodes = ctx.Blueprint.Nodes.OfType().ToList(); + + if (constNodes.Count > 0 || varNodes.Count > 0) { sb.AppendLine(MarkerConstBlock); foreach (var cn in constNodes) @@ -34,6 +37,10 @@ public void Assemble(ReverseConversionContext ctx) sb.AppendLine($"{cn.ConstType} {cn.ConstName} = {value};"); } } + foreach (var vn in varNodes) + { + sb.AppendLine($"{vn.VarType} {vn.VarName};"); + } sb.AppendLine(); } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs index 2daf2b5f..c56b2730 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs @@ -793,13 +793,25 @@ public Task ExecuteBlockScriptAsync( return ExecuteBlockScriptCoreAsync(sourceCode, helperFunctions, null, cancellationToken); } + /// + /// 从块脚本源代码执行(带辅助函数和常量覆盖) + /// + public Task ExecuteBlockScriptAsync( + string sourceCode, + List helperFunctions, + Dictionary? constantOverrides, + CancellationToken cancellationToken = default) + { + return ExecuteBlockScriptCoreAsync(sourceCode, helperFunctions, constantOverrides, cancellationToken); + } + /// /// 核心块脚本执行逻辑:解析 → 验证 → 执行 /// private async Task ExecuteBlockScriptCoreAsync( string sourceCode, List? helperFunctions, - Dictionary? parameters, + Dictionary? constantOverrides, CancellationToken cancellationToken) { // 1. Parse @@ -829,8 +841,20 @@ private async Task ExecuteBlockScriptCoreAsync( if (helperFunctions != null) parseResult.Script.HelperFunctions = helperFunctions; + // 3.5. Apply constant overrides from user edits (replaces DefaultValue before execution) + if (constantOverrides != null && parseResult.Script.ConstBlock != null) + { + foreach (var variable in parseResult.Script.ConstBlock.Variables) + { + if (constantOverrides.TryGetValue(variable.Name, out var userValue)) + { + variable.DefaultValue = userValue; + } + } + } + // 4. Execute - return await BlockScriptExecutor.ExecuteAsync(parseResult.Script, parameters, cancellationToken); + return await BlockScriptExecutor.ExecuteAsync(parseResult.Script, constantOverrides, cancellationToken); } #endregion diff --git a/KitX Clients/KitX Dashboard b/KitX Clients/KitX Dashboard index 57b84159..af1f9107 160000 --- a/KitX Clients/KitX Dashboard +++ b/KitX Clients/KitX Dashboard @@ -1 +1 @@ -Subproject commit 57b841599e6c6434ecb895fc1164026192270d06 +Subproject commit af1f91074c50c23188fb0cc8885b69590ee8eab8 diff --git a/KitX Standard b/KitX Standard index 1788c034..ff4aa0dd 160000 --- a/KitX Standard +++ b/KitX Standard @@ -1 +1 @@ -Subproject commit 1788c03405428dd6744a7cb1a116f7e98cc2e177 +Subproject commit ff4aa0dd6623d0796702bbcda0e503afe1ada21b From 7ff97b47f8e848733321780261720b5e598929e4 Mon Sep 17 00:00:00 2001 From: StarInk Date: Thu, 9 Apr 2026 21:30:18 +0200 Subject: [PATCH 040/110] =?UTF-8?q?=F0=9F=A7=A9=20Refactor(LayoutService):?= =?UTF-8?q?=20Update=20layout=20constants=20and=20improve=20data=20node=20?= =?UTF-8?q?placement=20logic=20for=20better=20organization=20and=20readabi?= =?UTF-8?q?lity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Workflow/Blueprint/LayoutService.cs | 152 +++++++++++------- 1 file changed, 98 insertions(+), 54 deletions(-) diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/LayoutService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/LayoutService.cs index e638949e..a5dfc84b 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/LayoutService.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/LayoutService.cs @@ -17,14 +17,19 @@ public class LayoutService : ILayoutService // Layout constants private const double HSpacing = 220; private const double VSpacing = 130; - private const double ForkVGap = 60; - private const double ForkHGap = 40; - private const double MaxRowWidth = 1200; + private const double ForkVGap = 100; + private const double ForkHGap = 300; + private const double MaxRowWidth = 660; private const double XOffset = 50; private const double YOffset = 50; private const double NodeWidth = 200; private const double NodeHeight = 100; + // Data node sidebar constants + private const double DataSidebarX = -350; + private const double ConstSidebarX = -350; + private const double DataNodeVSpacing = 150; + /// public void LayoutNodes(Contract.Workflow.Blueprint blueprint) { @@ -56,7 +61,7 @@ public void LayoutNodes(Contract.Workflow.Blueprint blueprint) rootRegion.Arrange(XOffset, YOffset, blueprint); } - // Phase 5: Place data nodes near their consumers + // Phase 5: Place data nodes in sidebar PlaceDataNodes(blueprint, placed); // Phase 6: Log final positions @@ -69,24 +74,31 @@ public void LayoutNodes(Contract.Workflow.Blueprint blueprint) } /// - /// Builds nodeId → [(pinName, targetNodeId)] mapping for exec-type connections only + /// Builds nodeId → [(pinName, targetNodeId)] mapping for exec-type connections only. + /// Iterates by node OutputPins order (visual top-to-bottom) to ensure branch + /// direction assignment matches physical pin layout. /// private Dictionary> BuildExecAdjacencyMap( Contract.Workflow.Blueprint blueprint) { var map = new Dictionary>(); - foreach (var conn in blueprint.Connections) + foreach (var node in blueprint.Nodes) { - var sourceNode = blueprint.GetNodeById(conn.SourceNodeId); - if (sourceNode == null) continue; + var execPins = node.OutputPins.Where(p => p.Type == PinType.Execution).ToList(); + if (execPins.Count == 0) continue; - var sourcePin = sourceNode.OutputPins.FirstOrDefault(p => p.Id == conn.SourcePinId); - if (sourcePin == null || sourcePin.Type != PinType.Execution) continue; + var targets = new List<(string PinName, string TargetId)>(); + foreach (var pin in execPins) + { + var conn = blueprint.Connections.FirstOrDefault(c => + c.SourceNodeId == node.Id && c.SourcePinId == pin.Id); + if (conn != null) + targets.Add((pin.Name, conn.TargetNodeId)); + } - if (!map.ContainsKey(conn.SourceNodeId)) - map[conn.SourceNodeId] = []; - map[conn.SourceNodeId].Add((sourcePin.Name, conn.TargetNodeId)); + if (targets.Count > 0) + map[node.Id] = targets; } Log.Debug("[Layout] Exec adjacency map: {Count} nodes with exec outputs", map.Count); @@ -95,6 +107,8 @@ public void LayoutNodes(Contract.Workflow.Blueprint blueprint) /// /// Recursively builds a tree of LayoutRegions from the exec chain. + /// Fork nodes (Branch, Loop, etc.) are kept in the parent LinearRegion + /// so they appear at the end of the linear chain, not at the base X. /// private LayoutRegion? BuildRegionTree( string nodeId, @@ -143,7 +157,9 @@ public void LayoutNodes(Contract.Workflow.Blueprint blueprint) if (targets.Count >= 2) { - // Fork: Branch (True/False), Loop (LoopBody/LoopEnd), or any N-way control flow + // Fork: Branch (True/False), Loop (LoopBody/LoopEnd), or any N-way control flow. + // The fork node stays in a LinearRegion (so it's placed at end of chain), + // and the ForkRegion (branches only) becomes its Child. placed.Add(nodeId); var node = blueprint.GetNodeById(nodeId); @@ -157,27 +173,51 @@ public void LayoutNodes(Contract.Workflow.Blueprint blueprint) branches.Add(BuildRegionTree(target.TargetId, execMap, blueprint, visited, placed)); } - return new ForkRegion(nodeId, branches); + var linear = new LinearRegion(nodeId); + linear.Child = new ForkRegion(nodeId, branches); + return linear; } - // Fallback: 3+ exec outputs (treat as linear) + // Fallback: treat as linear placed.Add(nodeId); return new LinearRegion(nodeId); } /// - /// Places data-only nodes (Const, etc.) near their first consumer + /// Places data-only nodes (Const, Variable) in a left sidebar column. /// private void PlaceDataNodes(Contract.Workflow.Blueprint blueprint, HashSet placed) { var dataNodes = blueprint.Nodes.Where(n => !placed.Contains(n.Id)).ToList(); if (dataNodes.Count == 0) return; - Log.Debug("[Layout] Placing {Count} data nodes (Const, etc.)", dataNodes.Count); + Log.Debug("[Layout] Placing {Count} data nodes (Const, Variable, etc.)", dataNodes.Count); - // Group data nodes by their first consumer target - var dataNodeIndex = 0; - foreach (var node in dataNodes) + var variableNodes = dataNodes.Where(n => n.NodeType == BlueprintNodeType.Variable).ToList(); + var constNodes = dataNodes.Where(n => n.NodeType == BlueprintNodeType.Const).ToList(); + var otherNodes = dataNodes.Where(n => + n.NodeType != BlueprintNodeType.Variable + && n.NodeType != BlueprintNodeType.Const).ToList(); + + // Variable nodes in the far-left column + for (int i = 0; i < variableNodes.Count; i++) + { + variableNodes[i].X = DataSidebarX; + variableNodes[i].Y = YOffset + i * DataNodeVSpacing; + placed.Add(variableNodes[i].Id); + } + + // Const nodes in the constant column, starting below variables + double constStartY = YOffset + variableNodes.Count * DataNodeVSpacing; + for (int i = 0; i < constNodes.Count; i++) + { + constNodes[i].X = ConstSidebarX; + constNodes[i].Y = constStartY + i * DataNodeVSpacing; + placed.Add(constNodes[i].Id); + } + + // Other data nodes: place near first consumer + foreach (var node in otherNodes) { var firstConn = blueprint.Connections .FirstOrDefault(c => c.SourceNodeId == node.Id); @@ -186,11 +226,9 @@ private void PlaceDataNodes(Contract.Workflow.Blueprint blueprint, HashSet 0 - ? _rows.Max(r => r.Count * NodeWidth + Math.Max(0, r.Count - 1) * (HSpacing - NodeWidth)) + ? _rows.Max(r => (r.Count - 1) * HSpacing + NodeWidth) : 0; MeasuredHeight = _rows.Count > 0 - ? _rows.Count * NodeHeight + Math.Max(0, _rows.Count - 1) * (VSpacing - NodeHeight) + ? (_rows.Count - 1) * VSpacing + NodeHeight : 0; // Include child region @@ -313,8 +351,10 @@ public override void Arrange(double x, double y, Contract.Workflow.Blueprint bp) } /// - /// Fork region: a fork node (Branch/Loop/etc.) with N sub-branches - /// arranged vertically, indented to the right. + /// Fork region: arranges N sub-branches vertically around a fork node + /// that has already been placed by the parent LinearRegion. + /// Does NOT place the fork node itself — only arranges branches + /// relative to the fork node's actual position in the blueprint. /// Supports any number of execution output arms (2 for Branch/Loop, N for future nodes). /// private class ForkRegion : LayoutRegion @@ -322,10 +362,6 @@ private class ForkRegion : LayoutRegion public string ForkNodeId; public List Branches; - // Backward-compatible convenience properties for 2-branch case - public LayoutRegion? UpperBranch => Branches.Count > 0 ? Branches[0] : null; - public LayoutRegion? LowerBranch => Branches.Count > 1 ? Branches[1] : null; - // Symmetric layout: upper / middle / lower branch groups private double _upperBranchHeight; private double _middleBranchHeight; @@ -351,21 +387,21 @@ private static int GetBranchDirection(int index, int total) public override void Measure(double availableWidth = MaxRowWidth) { - // Branches are indented to the right of the fork node - double branchAvailableWidth = Math.Max(NodeWidth, availableWidth - NodeWidth - ForkHGap); + // Branches use full availableWidth — ForkHGap is horizontal indent, not row-space + double branchAvailableWidth = availableWidth; // Measure all branches foreach (var branch in Branches) branch?.Measure(branchAvailableWidth); - // Width: fork node + gap + max branch width (unchanged) + // Width: gap + max branch width (fork node is in parent LinearRegion) double maxBranchWidth = Branches .Where(b => b != null) .Select(b => b!.MeasuredWidth) .DefaultIfEmpty(0) .Max(); - MeasuredWidth = NodeWidth + ForkHGap + maxBranchWidth; + MeasuredWidth = ForkHGap + maxBranchWidth; // Height: symmetric layout — group branches by direction _upperBranchHeight = 0; @@ -394,35 +430,43 @@ public override void Measure(double availableWidth = MaxRowWidth) } } - double middleAndForkHeight = Math.Max(NodeHeight, _middleBranchHeight); + double middleHeight = _middleBranchHeight; MeasuredHeight = _upperBranchHeight + (_upperBranchHeight > 0 ? ForkVGap : 0) - + middleAndForkHeight + + middleHeight + (lowerBranchHeight > 0 ? ForkVGap : 0) + lowerBranchHeight; } public override void Arrange(double x, double y, Contract.Workflow.Blueprint bp) { - double branchX = x + NodeWidth + ForkHGap; + // Read the fork node's actual position (placed by parent LinearRegion) + var forkNode = bp.GetNodeById(ForkNodeId); + if (forkNode == null) return; - // Fork node Y: offset down by upper branch height - double forkY = y + _upperBranchHeight - + (_upperBranchHeight > 0 ? ForkVGap : 0); + double branchBaseX = forkNode.X + forkNode.Width + ForkHGap; - // Place fork node - var forkNode = bp.GetNodeById(ForkNodeId); - if (forkNode != null) + // Upper branches start just above the fork node and flow downward. + // Lower branches start below the fork node, but also below any upper branches + // to avoid vertical overlap at the same X. + double upperStartY = forkNode.Y - ForkVGap; + + double maxUpperBottom = upperStartY; + for (int i = 0; i < Branches.Count; i++) { - forkNode.X = x; - forkNode.Y = forkY; + if (GetBranchDirection(i, Branches.Count) == -1) + { + var b = Branches[i]; + if (b != null && b.MeasuredHeight > 0) + maxUpperBottom = Math.Max(maxUpperBottom, upperStartY + b.MeasuredHeight); + } } - // Arrange branches by direction - double currentUpperY = y; - double middleAndForkHeight = Math.Max(NodeHeight, _middleBranchHeight); - double currentLowerY = forkY + middleAndForkHeight + ForkVGap; + double currentUpperY = upperStartY; + double currentLowerY = Math.Max( + forkNode.Y + forkNode.Height + ForkVGap, + maxUpperBottom + ForkVGap); for (int i = 0; i < Branches.Count; i++) { @@ -433,14 +477,14 @@ public override void Arrange(double x, double y, Contract.Workflow.Blueprint bp) switch (dir) { case -1: // upper-right - branch.Arrange(branchX, currentUpperY, bp); + branch.Arrange(branchBaseX, currentUpperY, bp); currentUpperY += branch.MeasuredHeight + VSpacing; break; case 0: // straight-right (same Y as fork) - branch.Arrange(branchX, forkY, bp); + branch.Arrange(branchBaseX, forkNode.Y, bp); break; case 1: // lower-right - branch.Arrange(branchX, currentLowerY, bp); + branch.Arrange(branchBaseX, currentLowerY, bp); currentLowerY += branch.MeasuredHeight + VSpacing; break; } From fbe40fb077726819e9aba07317d18a6e01dce9ae Mon Sep 17 00:00:00 2001 From: StarInk Date: Fri, 10 Apr 2026 01:58:21 +0200 Subject: [PATCH 041/110] =?UTF-8?q?=F0=9F=92=BE=20Feat(BlockScript/Bluepri?= =?UTF-8?q?nt):=20=E5=AE=9E=E7=8E=B0=E6=8F=92=E4=BB=B6=E5=87=BD=E6=95=B0?= =?UTF-8?q?=E5=85=A8=E9=93=BE=E8=B7=AF=E8=A7=A3=E6=9E=90=E4=B8=8E=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E5=8C=96=E6=89=A7=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 管线层(BS→BP): - ExprUtils 新增 GetFullMethodName() 保留完整点分路径 - FormattedStatement 新增 FullFunctionName 属性 - ScriptFormatter 在 FormatInvocation 和 ExpandExpression 中传递完整函数名 - NodeBuilder 解析 PluginName/FunctionName 到 CallNode - ExpandExpression 拆分嵌套调用时保留 FullFunctionName 执行层(BS Runtime): - BlockScriptExecutor 新增 PluginCallRewriter (Roslyn AST级转换) 将 A.B.C.Func(args) → PluginCall("A.B.C", "Func", args) - BlockScriptExecutionGlobals 新增 PluginCall 统一入口 - RealPluginManager 新增 CallAuto() 自动分发策略: void → fire-and-forget, 非void → 反射调用 Call() - RealPluginManager 新增 GetFunctionReturnType() 查询插件函数返回类型 - ParseResult 增加非JSON纯文本容错 - CoreServiceCollectionExtensions/WorkflowScriptService 注入 PluginManager 导出层(BP→BS): - CallNodeExportStrategy 已正确处理 PluginName 生成完整调用路径 --- .../DI/CoreServiceCollectionExtensions.cs | 11 ++ .../BlockScriptExecutionGlobals.cs | 49 +++++++ .../BlockScripting/BlockScriptExecutor.cs | 132 +++++++++++++++++- .../Workflow/Blueprint/Pipeline/ExprUtils.cs | 16 ++- .../Pipeline/FormattedBlockScript.cs | 7 + .../Blueprint/Pipeline/NodeBuilder.cs | 14 +- .../Blueprint/Pipeline/ScriptFormatter.cs | 12 +- .../KitX.Core/Workflow/RealPluginManager.cs | 88 +++++++++++- .../Workflow/WorkflowScriptService.cs | 20 ++- KitX Clients/KitX Dashboard | 2 +- 10 files changed, 337 insertions(+), 14 deletions(-) diff --git a/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs b/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs index 68c77b4f..07c3666f 100644 --- a/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs +++ b/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs @@ -188,6 +188,17 @@ public static IServiceCollection AddCoreServices(this IServiceCollection service services.AddSingleton(provider => { var service = new KitX.Core.Workflow.BlockScripting.BlockScriptExecutor(); + // Wire up plugin manager if PluginsServer is available + try + { + var pluginManager = new KitX.Core.Workflow.RealPluginManager( + KitX.Core.Device.PluginsServer.Instance); + service.SetPluginManager(pluginManager); + } + catch (Exception ex) + { + Log.Warning(ex, "Could not initialize BlockScriptExecutor with plugin manager"); + } return service; }); diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutionGlobals.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutionGlobals.cs index 3888ddcb..fce1103d 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutionGlobals.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutionGlobals.cs @@ -1,7 +1,10 @@ using System; using System.Collections.Generic; using System.Threading; +using Kscript.CSharp.Parser.Core; +using Kscript.CSharp.Parser.Models; using KitX.Core.Workflow; +using Serilog; namespace KitX.Core.Workflow.BlockScripting; @@ -13,6 +16,7 @@ public class BlockScriptExecutionGlobals private readonly BlockScopeManager _scopeManager; private readonly List _output; private readonly Dictionary _variables = new(); + private readonly IPluginManager? _pluginManager; /// /// NextBlock 内置变量 - 设置后执行器会跳转到指定块 @@ -29,6 +33,15 @@ public BlockScriptExecutionGlobals(BlockScopeManager scopeManager, List _output = output; } + /// + /// Creates script globals with plugin manager support + /// + public BlockScriptExecutionGlobals(BlockScopeManager scopeManager, List output, + IPluginManager? pluginManager) : this(scopeManager, output) + { + _pluginManager = pluginManager; + } + /// /// Gets a variable value - called by CSharpScript when accessing unknown properties /// Returns dynamic to allow implicit conversion to target variable types @@ -127,4 +140,40 @@ public void Pause(int milliseconds) NextBlock = $"{parentBlockName}_Loop"; return NextBlock; } + + /// + /// 调用插件函数。所有分发策略(类型化调用、fire-and-forget vs 同步等待) + /// 由 RealPluginManager.CallAuto() 内部自动完成,调用方无需关心。 + /// + public object? PluginCall(string pluginName, string methodName, params object?[] args) + { + if (_pluginManager == null) + { + Log.Warning("[BlockScriptGlobals] PluginCall: no plugin manager available, " + + "cannot call {PluginName}.{MethodName}", pluginName, methodName); + return null; + } + + var callInfo = new PluginCallInfo + { + PluginName = pluginName, + MethodName = methodName, + Parameters = args ?? Array.Empty() + }; + + try + { + if (_pluginManager is RealPluginManager realManager) + return realManager.CallAuto(callInfo); + + // Fallback for other IPluginManager implementations + return _pluginManager.Call(callInfo); + } + catch (Exception ex) + { + Log.Error(ex, "[BlockScriptGlobals] PluginCall failed: {PluginName}.{MethodName}", + pluginName, methodName); + return null; + } + } } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutor.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutor.cs index dbf43a97..56bbd7a7 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutor.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutor.cs @@ -5,6 +5,10 @@ using System.Reflection; using System.Threading; using System.Threading.Tasks; +using Kscript.CSharp.Parser.Core; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.CSharp.Scripting; using Microsoft.CodeAnalysis.Scripting; using KitX.Core.Contract.Workflow; @@ -24,6 +28,7 @@ public class BlockScriptExecutor : IBlockScriptExecutor private BlockScript? _currentScript; private List _output = new(); private BlockScriptExecutionGlobals? _globals; + private IPluginManager? _pluginManager; /// /// Creates a new block script executor @@ -41,6 +46,22 @@ public BlockScriptExecutor(BlockScopeManager scopeManager) _scopeManager = scopeManager; } + /// + /// Creates a new block script executor with plugin manager support + /// + public BlockScriptExecutor(IPluginManager? pluginManager) : this() + { + _pluginManager = pluginManager; + } + + /// + /// Sets the plugin manager for plugin function calls during execution. + /// + public void SetPluginManager(IPluginManager? pluginManager) + { + _pluginManager = pluginManager; + } + /// /// Executes a block script /// @@ -488,6 +509,9 @@ private async Task ExecuteExpressionAsync( { try { + // Pre-process: rewrite dotted plugin calls (e.g. "Plugin.Func()") to PluginCall(...) + expression = PreProcessPluginCalls(expression); + // Build a complete statement for execution var code = WrapExpressionAsStatement(expression); @@ -524,7 +548,7 @@ private async Task ExecuteExpressionAsync( else { // First evaluation: create initial script state using RunAsync to get ScriptState - _globals = new BlockScriptExecutionGlobals(_scopeManager, _output); + _globals = new BlockScriptExecutionGlobals(_scopeManager, _output, _pluginManager); var globals = _globals; _scriptState = await CSharpScript.RunAsync( fullCode, @@ -560,6 +584,106 @@ private string WrapExpressionAsStatement(string expression) return expression + ";"; } + /// + /// Preprocesses an expression to rewrite dotted plugin calls into PluginCall() invocations. + /// Uses Roslyn SyntaxRewriter for safe, AST-level transformation that correctly handles + /// nested parentheses, complex expressions, and all edge cases. + /// Transforms: TestPlugin.WPF.Core.HelloKitX(arg1, arg2) + /// Into: PluginCall("TestPlugin.WPF.Core", "HelloKitX", arg1, arg2) + /// Transforms: TestPlugin.WPF.Core.HelloKitX() + /// Into: PluginCall("TestPlugin.WPF.Core", "HelloKitX") + /// + private static string PreProcessPluginCalls(string expression) + { + try + { + var wrappedCode = "_ = " + expression + ";"; + var syntaxTree = CSharpSyntaxTree.ParseText(wrappedCode); + var root = syntaxTree.GetCompilationUnitRoot(); + + var rewriter = new PluginCallRewriter(); + var rewritten = rewriter.Visit(root); + + // Extract the expression back from "_ = ...;" + if (rewritten is CompilationUnitSyntax cu + && cu.Members.FirstOrDefault() is GlobalStatementSyntax gs + && gs.Statement is ExpressionStatementSyntax ess + && ess.Expression is AssignmentExpressionSyntax aes) + { + var result = aes.Right.ToString(); + if (result != expression) + { + Log.Debug("[BlockScriptExecutor] PreProcessPluginCalls: '{Original}' → '{Result}'", + expression, result); + } + return result; + } + + Log.Warning("[BlockScriptExecutor] PreProcessPluginCalls: extraction failed for '{Expression}', " + + "rewritten type={Type}", expression, rewritten?.GetType().Name); + return expression; + } + catch (Exception ex) + { + Log.Warning(ex, "[BlockScriptExecutor] PreProcessPluginCalls: exception for '{Expression}'", + expression); + return expression; + } + } + + /// + /// Roslyn SyntaxRewriter that transforms dotted member-access invocations + /// (e.g. TestPlugin.WPF.Core.HelloKitX()) into PluginCall("...", "...", args) calls. + /// Operates at the AST level, preserving all expression structure and handling nesting. + /// + private class PluginCallRewriter : CSharpSyntaxRewriter + { + public override SyntaxNode VisitInvocationExpression(InvocationExpressionSyntax node) + { + // First, recursively rewrite any nested invocations in arguments + node = (InvocationExpressionSyntax)base.VisitInvocationExpression(node)!; + + // Only rewrite member-access invocations (e.g. A.B.C.Method()) + if (node.Expression is not MemberAccessExpressionSyntax member) + return node; + + // Only rewrite when the expression has dots (plugin namespace path) + var fullExpression = member.Expression.ToString(); + if (!fullExpression.Contains('.')) + return node; + + var pluginName = fullExpression; + var funcName = member.Name.Identifier.Text; + + Log.Debug("[PluginCallRewriter] Rewriting: {Plugin}.{Method}() → PluginCall()", + pluginName, funcName); + + // Build: PluginCall("pluginName", "funcName" [, existingArgs]) + // 所有分发策略(类型化调用、f-a-f vs 同步等待)由 Manager 内部自动处理 + var args = new List + { + SyntaxFactory.Argument( + SyntaxFactory.LiteralExpression( + SyntaxKind.StringLiteralExpression, + SyntaxFactory.Literal(pluginName))), + SyntaxFactory.Argument( + SyntaxFactory.LiteralExpression( + SyntaxKind.StringLiteralExpression, + SyntaxFactory.Literal(funcName))) + }; + + // Append original arguments + args.AddRange(node.ArgumentList.Arguments); + + var newArgsList = SyntaxFactory.SeparatedList(args); + var newInvocation = SyntaxFactory.InvocationExpression( + SyntaxFactory.IdentifierName("PluginCall"), + SyntaxFactory.ArgumentList(newArgsList)); + + return newInvocation; + } + } + /// /// Builds helper function code from a list of helper functions /// @@ -644,7 +768,7 @@ private async Task InitializeScriptSessionAsync(CancellationToken cancellationTo if (string.IsNullOrWhiteSpace(initCode)) { // No initialization needed, just do a simple first evaluation to establish session - _globals = new BlockScriptExecutionGlobals(_scopeManager, _output); + _globals = new BlockScriptExecutionGlobals(_scopeManager, _output, _pluginManager); _scriptState = await CSharpScript.RunAsync( "0", // Simple expression to establish session ScriptOptions.Default @@ -659,7 +783,7 @@ private async Task InitializeScriptSessionAsync(CancellationToken cancellationTo try { - _globals = new BlockScriptExecutionGlobals(_scopeManager, _output); + _globals = new BlockScriptExecutionGlobals(_scopeManager, _output, _pluginManager); _scriptState = await CSharpScript.RunAsync( initCode, ScriptOptions.Default @@ -672,7 +796,7 @@ private async Task InitializeScriptSessionAsync(CancellationToken cancellationTo { Log.Warning(ex, "[BlockScriptExecutor] Initialization script failed, will try without it"); // Fall back to simple session establishment - _globals = new BlockScriptExecutionGlobals(_scopeManager, _output); + _globals = new BlockScriptExecutionGlobals(_scopeManager, _output, _pluginManager); _scriptState = await CSharpScript.RunAsync( "0", ScriptOptions.Default diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ExprUtils.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ExprUtils.cs index 2efa3a42..eafe8d44 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ExprUtils.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ExprUtils.cs @@ -82,7 +82,7 @@ public static (ExpressionSyntax? rightExpr, string? assignedVar)? ParseStatement catch { return (null, null); } } - /// Extracts method name from an invocation expression. + /// Extracts the short method name from an invocation expression. public static string GetMethodName(InvocationExpressionSyntax invoke) { if (invoke.Expression is IdentifierNameSyntax id) return id.Identifier.Text; @@ -91,6 +91,20 @@ public static string GetMethodName(InvocationExpressionSyntax invoke) return string.Empty; } + /// + /// Extracts the full dotted method path from an invocation expression. + /// For "TestPlugin.WPF.Core.HelloKitX()" returns "TestPlugin.WPF.Core.HelloKitX". + /// For simple calls like "Get(...)" returns just "Get". + /// + public static string GetFullMethodName(InvocationExpressionSyntax invoke) + { + if (invoke.Expression is IdentifierNameSyntax id) return id.Identifier.Text; + if (invoke.Expression is GenericNameSyntax generic) return generic.Identifier.Text; + if (invoke.Expression is MemberAccessExpressionSyntax member) + return member.Expression.ToString() + "." + member.Name.Identifier.Text; + return string.Empty; + } + /// Checks if a token is a simple variable reference. public static bool IsVariableReference(string token) { diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/FormattedBlockScript.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/FormattedBlockScript.cs index 65b7b122..c30557ba 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/FormattedBlockScript.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/FormattedBlockScript.cs @@ -57,9 +57,16 @@ public class FormattedStatement // --- For function calls --- /// /// Function name (e.g. "HelperFuncCompare", "Get", "Set", "Print"). + /// For plugin calls, this is the short name (e.g. "HelloKitX"). /// public string? FunctionName { get; set; } + /// + /// Full dotted method path for plugin/external calls (e.g. "TestPlugin.WPF.Core.HelloKitX"). + /// Null for built-in and helper functions. + /// + public string? FullFunctionName { get; set; } + /// /// Raw argument strings after expansion (no nested calls). /// Each argument is either a literal, a PubVar name, a ConstBlock variable name, or Get("varName"). diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/NodeBuilder.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/NodeBuilder.cs index 1f65f59e..a36f6542 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/NodeBuilder.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/NodeBuilder.cs @@ -227,7 +227,19 @@ or FormattedStatementKind.LoopBodyEnd else { var callNode = (CallNode)_registry.Create(BlueprintNodeType.Call); - callNode.FunctionName = stmt.FunctionName!; + + // Parse plugin name from full dotted method name (e.g. "TestPlugin.WPF.Core.HelloKitX") + if (!string.IsNullOrEmpty(stmt.FullFunctionName) && stmt.FullFunctionName.Contains('.')) + { + var lastDot = stmt.FullFunctionName.LastIndexOf('.'); + callNode.PluginName = stmt.FullFunctionName.Substring(0, lastDot); + callNode.FunctionName = stmt.FullFunctionName.Substring(lastDot + 1); + } + else + { + callNode.FunctionName = stmt.FunctionName!; + } + mainNode = callNode; } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ScriptFormatter.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ScriptFormatter.cs index 2cb6f878..7d84761e 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ScriptFormatter.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ScriptFormatter.cs @@ -214,7 +214,8 @@ private List FormatExpressionStatement(ExpressionStatement e // Skip flow control functions (handled by FlowControlStatement) if (ExprUtils.FlowControlFunctions.Contains(funcName)) return result; - result.AddRange(FormatInvocation(invoke, funcName, blockName, context, assignedVar)); + var fullFuncName = ExprUtils.GetFullMethodName(invoke); + result.AddRange(FormatInvocation(invoke, funcName, blockName, context, assignedVar, fullFuncName)); return result; } @@ -227,7 +228,7 @@ private List FormatExpressionStatement(ExpressionStatement e /// private List FormatInvocation( InvocationExpressionSyntax invoke, string funcName, string blockName, - PipelineContext context, string? assignedVar) + PipelineContext context, string? assignedVar, string? fullFuncName = null) { var result = new List(); @@ -303,6 +304,7 @@ private List FormatInvocation( BlockName = blockName, Kind = kind, FunctionName = funcName, + FullFunctionName = fullFuncName, PubVarTarget = pubVarTarget, SetVarName = setVarName, GetVarName = getVarName, @@ -358,6 +360,7 @@ private List FormatInvocation( if (expr is InvocationExpressionSyntax invoke) { var funcName = ExprUtils.GetMethodName(invoke); + var fullFuncName = ExprUtils.GetFullMethodName(invoke); // Built-in functions (Get/Set/Print/Pause) stay inline if (ExprUtils.NonExtractableFunctions.Contains(funcName)) @@ -422,6 +425,7 @@ private List FormatInvocation( Kind = FormattedStatementKind.Assignment, PubVarTarget = pubVarName, FunctionName = funcName, + FullFunctionName = fullFuncName, Arguments = currentArgs, OriginalExpression = $"{pubVarName} = {invoke}", Fingerprint = fingerprint @@ -476,7 +480,8 @@ private List FormatInvocation( var funcName = ExprUtils.GetMethodName(invoke); if (string.IsNullOrEmpty(funcName)) return (result, null); - var formatted = FormatInvocation(invoke, funcName, blockName, context, null); + var fullFuncName = ExprUtils.GetFullMethodName(invoke); + var formatted = FormatInvocation(invoke, funcName, blockName, context, null, fullFuncName); // The last statement should be the main call // If it already has a PubVarTarget, use it @@ -551,6 +556,7 @@ private void InsertLoopConditionDuplications(FormattedBlockScript script, Pipeli Kind = source.Kind, PubVarTarget = source.PubVarTarget, FunctionName = source.FunctionName, + FullFunctionName = source.FullFunctionName, Arguments = new List(source.Arguments), ConditionExpression = source.ConditionExpression, ConditionPubVar = source.ConditionPubVar, diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/RealPluginManager.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/RealPluginManager.cs index 02ba927f..d1bdca60 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/RealPluginManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/RealPluginManager.cs @@ -152,6 +152,37 @@ public T Call(PluginCallInfo callInfo) return ParseResult(result); } + /// + /// 自动调用插件方法:根据函数声明的返回类型自动选择调用策略。 + /// - void 返回类型 → fire-and-forget (Call),不等待响应 + /// - 非 void 返回类型 → 类型化同步等待 (Call<T>),返回具体类型的结果 + /// 调用方无需关心分发逻辑,所有判断由 Manager 内部完成。 + /// + /// 插件调用信息 + /// 非 void 函数返回具体类型结果;void 函数返回 null + public object? CallAuto(PluginCallInfo callInfo) + { + var returnType = GetFunctionReturnType(callInfo.PluginName, callInfo.MethodName); + + if (returnType == null || returnType == typeof(void)) + { + Log.Information("[RealPluginManager] CallAuto: {Plugin}.{Method} → fire-and-forget (void/unknown)", + callInfo.PluginName, callInfo.MethodName); + Call(callInfo); + return null; + } + + Log.Information("[RealPluginManager] CallAuto: {Plugin}.{Method} → Call<{Type}>() (typed wait)", + callInfo.PluginName, callInfo.MethodName, returnType.Name); + + var typedCallMethod = typeof(IPluginManager) + .GetMethods() + .First(m => m.Name == "Call" && m.IsGenericMethod) + .MakeGenericMethod(returnType); + + return typedCallMethod.Invoke(this, new object[] { callInfo }); + } + /// /// 异步调用插件方法 /// @@ -357,7 +388,22 @@ private T ParseResult(string result) if (returnType == typeof(bool)) return (T)(object)bool.Parse(result); - // 对于复杂类型,从 JSON 反序列化 + // 对于 object 类型,先尝试 JSON 反序列化,失败则返回原始字符串 + // BlockScript 通过 PluginCall() 使用 Call(),插件可能返回 + // 纯文本(如 "Hello")而非 JSON 包装的值 + if (returnType == typeof(object)) + { + try + { + return (T)JsonSerializer.Deserialize(result, _serializerOptions)!; + } + catch (JsonException) + { + return (T)(object)result; + } + } + + // 对于其他复杂类型,从 JSON 反序列化 return JsonSerializer.Deserialize(result, _serializerOptions)!; } catch (Exception ex) @@ -367,6 +413,46 @@ private T ParseResult(string result) } } + /// + /// 获取插件方法的返回值类型,用于运行时类型化调用 + /// 与 MethodEmitter 编译期类型解析逻辑一致,但在运行时通过反射使用 + /// + /// 插件名称 (e.g. "TestPlugin.CSharp") + /// 方法名称 (e.g. "SayHello") + /// 返回值 Type,未找到则返回 null + public Type? GetFunctionReturnType(string pluginName, string methodName) + { + var connection = FindPluginConnection(pluginName); + if (connection?.PluginInfo?.Functions == null) + return null; + + var func = connection.PluginInfo.Functions.Find(f => f.Name == methodName); + if (string.IsNullOrEmpty(func.Name)) + return null; + + return MapReturnType(func.ReturnValueType); + } + + /// + /// 将字符串类型名映射为 Type,与 TypeMapper/MethodEmitter 保持一致 + /// + private static Type? MapReturnType(string? typeName) + { + return typeName?.ToLowerInvariant() switch + { + null or "" => null, + "void" => typeof(void), + "string" => typeof(string), + "int" => typeof(int), + "long" => typeof(long), + "float" => typeof(float), + "double" => typeof(double), + "bool" => typeof(bool), + "object" => typeof(object), + _ => Type.GetType(typeName) ?? typeof(object) + }; + } + /// /// 检查插件是否存在 /// diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs index c56b2730..8e247c5d 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs @@ -692,10 +692,24 @@ public string MergeHelperFunctions(string mainCode, List helperF _blockScriptParser ??= new BlockScripting.BlockScriptParser(); /// - /// Gets the block script executor + /// Gets the block script executor (initialized with plugin manager) /// - private BlockScripting.BlockScriptExecutor BlockScriptExecutor => - _blockScriptExecutor ??= new BlockScripting.BlockScriptExecutor(); + private BlockScripting.BlockScriptExecutor BlockScriptExecutor + { + get + { + if (_blockScriptExecutor == null) + { + _blockScriptExecutor = new BlockScripting.BlockScriptExecutor(); + if (_isParserInitialized) + { + var realPluginManager = new RealPluginManager(PluginsServer.Instance); + _blockScriptExecutor.SetPluginManager(realPluginManager); + } + } + return _blockScriptExecutor; + } + } /// /// 解析块脚本 diff --git a/KitX Clients/KitX Dashboard b/KitX Clients/KitX Dashboard index af1f9107..d951f6c2 160000 --- a/KitX Clients/KitX Dashboard +++ b/KitX Clients/KitX Dashboard @@ -1 +1 @@ -Subproject commit af1f91074c50c23188fb0cc8885b69590ee8eab8 +Subproject commit d951f6c2c555cde747c05ab93cb93a2cefa18d09 From 94c5879b8487358d09b7a2b5eeaecd696095bd57 Mon Sep 17 00:00:00 2001 From: StarInk Date: Fri, 10 Apr 2026 03:14:55 +0200 Subject: [PATCH 042/110] =?UTF-8?q?=F0=9F=92=BE=20Feat(Core.Plugin):=20IPl?= =?UTF-8?q?uginServer=20=E6=8E=A5=E5=8F=A3=E6=9A=B4=E9=9C=B2=20Connections?= =?UTF-8?q?=20=E5=B1=9E=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - IPluginServer 新增 Connections 属性 (IReadOnlyList) - PluginsServer 显式实现接口, 将内部 IPluginConnection 列表转为 IPluginConnector - Dashboard 通过 DI 获取已连接插件函数列表, 遵循 Core/UI 分离架构 --- KitX Clients/KitX Core/KitX.Core/Device/PluginsServer.cs | 7 +++++++ KitX Clients/KitX Dashboard | 2 +- KitX Standard | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/KitX Clients/KitX Core/KitX.Core/Device/PluginsServer.cs b/KitX Clients/KitX Core/KitX.Core/Device/PluginsServer.cs index 3db642c3..23ecb7ce 100644 --- a/KitX Clients/KitX Core/KitX.Core/Device/PluginsServer.cs +++ b/KitX Clients/KitX Core/KitX.Core/Device/PluginsServer.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Text.Json; using System.Text.RegularExpressions; using System.Threading.Tasks; @@ -60,6 +61,12 @@ public class PluginsServer : IPluginServer /// public IReadOnlyList Connections => _connections.AsReadOnly(); + /// + /// IPluginServer.Connections — returns connected plugins as IPluginConnector list + /// + IReadOnlyList IPluginServer.Connections => + _connections.Cast().ToList().AsReadOnly(); + /// /// Event raised when a plugin connects /// diff --git a/KitX Clients/KitX Dashboard b/KitX Clients/KitX Dashboard index d951f6c2..f8c7e835 160000 --- a/KitX Clients/KitX Dashboard +++ b/KitX Clients/KitX Dashboard @@ -1 +1 @@ -Subproject commit d951f6c2c555cde747c05ab93cb93a2cefa18d09 +Subproject commit f8c7e8354d25265cd4f3faa38e4523d584a3710e diff --git a/KitX Standard b/KitX Standard index ff4aa0dd..27bbe787 160000 --- a/KitX Standard +++ b/KitX Standard @@ -1 +1 @@ -Subproject commit ff4aa0dd6623d0796702bbcda0e503afe1ada21b +Subproject commit 27bbe787977fc04a15a33aeef112161f5fcee23e From 2dfb92154b2eb521be5ed9c49b73ec30ea45f7ab Mon Sep 17 00:00:00 2001 From: StarInk Date: Fri, 10 Apr 2026 04:22:11 +0200 Subject: [PATCH 043/110] =?UTF-8?q?=F0=9F=93=9D=20Chore(Submodules):=20Syn?= =?UTF-8?q?c?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- KitX Clients/KitX Dashboard | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/KitX Clients/KitX Dashboard b/KitX Clients/KitX Dashboard index f8c7e835..7861beb4 160000 --- a/KitX Clients/KitX Dashboard +++ b/KitX Clients/KitX Dashboard @@ -1 +1 @@ -Subproject commit f8c7e8354d25265cd4f3faa38e4523d584a3710e +Subproject commit 7861beb4bdce635bd423d9a6b77ac00c766e20a1 From 2f7ef6db93fcc2b31e2ef227bc9dd23ed591e1ff Mon Sep 17 00:00:00 2001 From: StarInk Date: Fri, 10 Apr 2026 18:23:00 +0200 Subject: [PATCH 044/110] =?UTF-8?q?=F0=9F=A7=A9=20Refactor(BlockScript):?= =?UTF-8?q?=20=E5=BC=95=E5=85=A5=20IBuiltinFunctionDefinition=20=E8=87=AA?= =?UTF-8?q?=E6=8F=8F=E8=BF=B0=E5=87=BD=E6=95=B0=E6=B3=A8=E5=86=8C=E4=BD=93?= =?UTF-8?q?=E7=B3=BB=EF=BC=8C=E6=B6=88=E9=99=A4=E6=95=A3=E5=BC=B9=E5=BC=8F?= =?UTF-8?q?=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 引入 BuiltinFunctionRegistry + IBuiltinFunctionDefinition 接口,使内置函数(Print/Pause/Set/ Get/Branch/Loop/LoopBodyEnd/Break/Flip)通过单一实现类自描述格式化、节点构建与导出逻辑, 新增函数只需添加一个文件即可全链路生效。 核心变更: - 新增 IBuiltinFunctionDefinition 接口 (LegacyNodeType, IsFlowControl, IsBlockTerminator, OnNodeCreated, ConfigureNode, FormatInvocation, ToStatement) - 新增 BuiltinFunctionRegistry 反射自发现注册,替代手工 DI 注册 - 新增 BuiltinFunctionNode 通用节点类型,NodeBuilder/ScriptFormatter/BlockStatementExtractor 统一通过 registry 查询处理新函数 - 新增 BuiltinFunctionExportStrategyAdapter 桥接 INodeExportStrategy,BlueprintToBlockScriptConverter 按 NodeType + FunctionName 双字典路由 - ExecutionFlowWalker 内联 Call/CallHelper 导出逻辑,支持 BuiltinFunctionNode 类型判断 - BlockScriptExecutionGlobals 新增 ResetRunState/Flip 方法 - BlockScriptExecutor 执行入口调用 ResetRunState 重置运行级状态 文件变更: +9 新增 BuiltinFunctions/*.cs (Print/Pause/Set/Get/Branch/Loop/LoopBodyEnd/Break/Flip) +1 新增 BuiltinFunctionExportStrategyAdapter.cs -9 删除 ExportStrategies/*.cs (旧手工策略,由 BuiltinFunction 自描述替代) 修改 NodeBuilder/ScriptFormatter/BlockStatementExtractor/DI 注册等 14 个文件 测试: 全部 11 个测试通过 (含新增 Test K Forward→Reverse→Execute 执行验证) --- .../KitX.Core.BluePrint.Test/Program.cs | 114 ++++++++++++++++ .../DI/CoreServiceCollectionExtensions.cs | 43 +++--- .../BlockScriptExecutionGlobals.cs | 20 +++ .../BlockScripting/BlockScriptExecutor.cs | 3 + .../BlockScripting/BlockScriptParser.cs | 9 +- .../BlockScripting/BlockStatementExtractor.cs | 34 +++++ .../BlockScripting/BuiltinFunctionRegistry.cs | 86 ++++++++++++ .../BuiltinFunctions/BranchFunction.cs | 99 ++++++++++++++ .../BuiltinFunctions/BreakFunction.cs | 59 +++++++++ .../BuiltinFunctions/FlipFunction.cs | 116 +++++++++++++++++ .../BuiltinFunctions/GetFunction.cs | 99 ++++++++++++++ .../BuiltinFunctions/LoopBodyEndFunction.cs | 80 ++++++++++++ .../BuiltinFunctions/LoopFunction.cs | 108 +++++++++++++++ .../BuiltinFunctions/PauseFunction.cs | 65 +++++++++ .../BuiltinFunctions/PrintFunction.cs | 65 +++++++++ .../BuiltinFunctions/SetFunction.cs | 88 +++++++++++++ .../IBuiltinFunctionDefinition.cs | 115 ++++++++++++++++ .../BlockScriptToBlueprintConverter.cs | 15 ++- .../BlueprintToBlockScriptConverter.cs | 21 ++- .../BuiltinFunctionExportStrategyAdapter.cs | 39 ++++++ .../Workflow/Blueprint/ExecutionFlowWalker.cs | 92 ++++++++++++- .../BranchNodeExportStrategy.cs | 31 ----- .../BreakNodeExportStrategy.cs | 21 --- .../CallHelperNodeExportStrategy.cs | 24 ---- .../CallNodeExportStrategy.cs | 27 ---- .../ExportStrategies/GetNodeExportStrategy.cs | 24 ---- .../LoopNodeExportStrategy.cs | 31 ----- .../PauseNodeExportStrategy.cs | 22 ---- .../PrintNodeExportStrategy.cs | 23 ---- .../ExportStrategies/SetNodeExportStrategy.cs | 25 ---- .../Workflow/Blueprint/NodeRegistry.cs | 45 +++++++ .../Workflow/Blueprint/Pipeline/ExprUtils.cs | 15 +++ .../Blueprint/Pipeline/NodeBuilder.cs | 123 +++++++++++++++--- .../Blueprint/Pipeline/PipelineContext.cs | 22 +++- .../Blueprint/Pipeline/ScriptFormatter.cs | 19 ++- .../ReversePipeline/BlueprintAnalyzer.cs | 5 +- KitX Standard | 2 +- 37 files changed, 1547 insertions(+), 282 deletions(-) create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctionRegistry.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/BranchFunction.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/BreakFunction.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/FlipFunction.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/GetFunction.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/LoopBodyEndFunction.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/LoopFunction.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PauseFunction.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PrintFunction.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/SetFunction.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/IBuiltinFunctionDefinition.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BuiltinFunctionExportStrategyAdapter.cs delete mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/BranchNodeExportStrategy.cs delete mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/BreakNodeExportStrategy.cs delete mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/CallHelperNodeExportStrategy.cs delete mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/CallNodeExportStrategy.cs delete mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/GetNodeExportStrategy.cs delete mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/LoopNodeExportStrategy.cs delete mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/PauseNodeExportStrategy.cs delete mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/PrintNodeExportStrategy.cs delete mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/SetNodeExportStrategy.cs diff --git a/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs b/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs index be5dd5ee..310216b9 100644 --- a/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs +++ b/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs @@ -123,6 +123,13 @@ public static void Main(string[] args) Console.WriteLine("└──────────────────────────────────────────┘\n"); RunTest(converter, GetSingleStatementScript(), helpers, "Test J"); + + // ── Test K: Forward → Reverse → Execute round-trip ── + Console.WriteLine("\n┌──────────────────────────────────────────┐"); + Console.WriteLine("│ Test K: Forward → Reverse → Execute │"); + Console.WriteLine("└──────────────────────────────────────────┘\n"); + + RunExecutionTest(converter, reverseConverter, parser, sp, GetRawNestedScript(), helpers, "Test K"); } private static void RunTest(BlockScriptToBlueprintConverter converter, @@ -223,6 +230,113 @@ private static void RunTest(BlockScriptToBlueprintConverter converter, } // ────────────────────────────────────────────── + // Test K: Forward → Reverse → Execute round-trip + // ────────────────────────────────────────────── + private static void RunExecutionTest( + BlockScriptToBlueprintConverter forwardConverter, + IBlueprintToBlockScriptConverter reverseConverter, + IBlockScriptParser parser, + System.IServiceProvider sp, + string sourceCode, List helpers, string label) + { + try + { + // Step 1: Forward convert (Script → Blueprint) + var blueprint = forwardConverter.Convert(sourceCode, helpers); + Console.WriteLine($" [{label}] Forward: {blueprint.Nodes.Count} nodes, {blueprint.Connections.Count} connections"); + + // Step 2: Reverse convert (Blueprint → Expanded Script) + var expandedScript = reverseConverter.Convert(blueprint); + Console.WriteLine($" [{label}] Expanded script length: {expandedScript.Length} chars"); + + // Step 3: Parse the expanded script + var parseResult = parser.Parse(expandedScript); + if (!parseResult.IsSuccess || parseResult.Script == null) + { + Console.WriteLine($"[{label}] FAILED: Parse error: {parseResult.ErrorMessage}"); + return; + } + + // Attach helper functions with execution bodies + parseResult.Script.HelperFunctions = GetExecutionHelpers(); + + // Step 4: Execute + var executor = sp.GetRequiredService(); + var result = executor.ExecuteAsync(parseResult.Script).GetAwaiter().GetResult(); + + // Step 5: Verify output + // Expected: guessNum=5, targetNum=7, loopMax=3 + // Loop iterates while currentLoop <= 3 → 4 iterations (0,1,2,3) + // guessNum < targetNum → always "猜小了" + var expected = new List + { + "开始执行工作流", + "0", "猜小了", + "1", "猜小了", + "2", "猜小了", + "3", "猜小了", + "示例工作流结束" + }; + + Console.WriteLine($"\n Execution output ({result.Output.Count} lines):"); + foreach (var line in result.Output) + Console.WriteLine($" {line}"); + + bool match = result.IsSuccess && result.Output.Count == expected.Count; + if (match) + { + for (int i = 0; i < expected.Count; i++) + { + if (result.Output[i] != expected[i]) + { + match = false; + Console.WriteLine($" Mismatch at line {i}: expected '{expected[i]}', got '{result.Output[i]}'"); + } + } + } + else if (result.Output.Count != expected.Count) + { + Console.WriteLine($" Output count differs: expected {expected.Count}, got {result.Output.Count}"); + } + + Console.WriteLine($"\n[{label}] {(match ? "PASS - Execution output correct!" : (result.IsSuccess ? "DIFF - See differences above" : $"FAILED - {result.ErrorMessage}"))}"); + } + catch (Exception ex) + { + Console.WriteLine($"[{label}] FAILED: {ex.Message}"); + Console.WriteLine($" {ex.StackTrace}"); + } + } + + /// + /// Helper functions with actual execution bodies (for execution test). + /// + private static List GetExecutionHelpers() => + [ + new() + { + Name = "HelperFuncCompare", + Parameters = + [ + new() { Name = "op", Type = "string" }, + new() { Name = "left", Type = "int" }, + new() { Name = "right", Type = "int" } + ], + ReturnType = "bool", + Code = "return op switch { \"BLE\" => left <= right, \"BEQ\" => left == right, \"BLT\" => left < right, \"BGT\" => left > right, \"BGE\" => left >= right, \"BNE\" => left != right, _ => false };" + }, + new() + { + Name = "HelperFuncAdd", + Parameters = + [ + new() { Name = "a", Type = "int" }, + new() { Name = "b", Type = "int" } + ], + ReturnType = "int", + Code = "return a + b;" + } + ]; // ────────────────────────────────────────────── // Test A: Pre-expanded format (already has PubVar assignments) // ────────────────────────────────────────────── private static string GetPreExpandedScript() => @"#ConstBlock diff --git a/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs b/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs index 07c3666f..1bfaff97 100644 --- a/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs +++ b/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs @@ -24,7 +24,6 @@ using KitX.Core.Workflow; using KitX.Core.Workflow.BlockScripting; using KitX.Core.Workflow.Blueprint; -using KitX.Core.Workflow.Blueprint.ExportStrategies; using KitX.Core.Event; using Serilog; @@ -180,7 +179,8 @@ public static IServiceCollection AddCoreServices(this IServiceCollection service Log.Information("Registering IBlockScriptParser..."); services.AddSingleton(provider => { - var service = new KitX.Core.Workflow.BlockScripting.BlockScriptParser(); + var funcRegistry = provider.GetService(); + var service = new KitX.Core.Workflow.BlockScripting.BlockScriptParser(funcRegistry); return service; }); @@ -211,27 +211,40 @@ public static IServiceCollection AddCoreServices(this IServiceCollection service // Blueprint Sub-services (must be registered before IBlueprintService) Log.Information("Registering Blueprint sub-services..."); - services.AddSingleton(); + + // Discover and register all IBuiltinFunctionDefinition implementations + var functionRegistry = BuiltinFunctionRegistry.Discover(typeof(BuiltinFunctionRegistry).Assembly); + services.AddSingleton(functionRegistry); + + // NodeRegistry with function registry for dynamic node creation + services.AddSingleton(provider => + { + var reg = provider.GetRequiredService(); + return new NodeRegistry(reg); + }); + services.AddSingleton(); services.AddSingleton(); // Blueprint Converters Log.Information("Registering IBlockScriptToBlueprintConverter..."); - services.AddSingleton(); + services.AddSingleton(provider => + { + var parser = provider.GetRequiredService(); + var nodeRegistry = provider.GetRequiredService(); + var layoutService = provider.GetRequiredService(); + var funcRegistry = provider.GetService(); + return new BlockScriptToBlueprintConverter(parser, nodeRegistry, layoutService, funcRegistry!); + }); Log.Information("Registering IBlueprintToBlockScriptConverter..."); services.AddSingleton(); - // Blueprint Export Strategies - Log.Information("Registering node export strategies..."); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); + // Blueprint Export Strategies — all auto-registered from IBuiltinFunctionDefinition implementations + Log.Information("Registering auto-discovered builtin function export strategies..."); + foreach (var def in functionRegistry.AllDefinitions) + { + services.AddSingleton(new BuiltinFunctionExportStrategyAdapter(def)); + } // Blueprint Services Log.Information("Registering IBlueprintService..."); diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutionGlobals.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutionGlobals.cs index fce1103d..6a8df58c 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutionGlobals.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutionGlobals.cs @@ -132,6 +132,26 @@ public void Pause(int milliseconds) Thread.Sleep(milliseconds); } + /// + /// Flip counter — tracks odd/even execution state. Reset on each run from Entry. + /// + private int _flipCounter = 0; + + /// + /// Resets run-level state (called when execution starts from Entry node) + /// + public void ResetRunState() => _flipCounter = 0; + + /// + /// Flip — alternating control flow. Routes to outputA on odd calls, outputB on even calls. + /// + public string? Flip(string outputA, string outputB) + { + _flipCounter++; + NextBlock = (_flipCounter % 2 == 1) ? outputA : outputB; + return NextBlock; + } + /// /// LoopBodyEnd - marks the end of a loop body and returns to the loop condition block /// diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutor.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutor.cs index 56bbd7a7..024aaefa 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutor.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutor.cs @@ -84,6 +84,9 @@ public async Task ExecuteAsync( _output.Clear(); // Clear the executor's output list _scriptState = null; + // Reset run-level state (e.g. Flip counter) for fresh execution from Entry + _globals?.ResetRunState(); + // Initialize global scope with ConstBlock and PubVarBlock _scopeManager.InitializeGlobalScope(script); diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptParser.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptParser.cs index 01a2a95c..c3431d28 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptParser.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptParser.cs @@ -16,7 +16,14 @@ namespace KitX.Core.Workflow.BlockScripting; public class BlockScriptParser : IBlockScriptParser { private readonly BlockSyntaxValidator _validator = new(); - private readonly BlockStatementExtractor _extractor = new(); + private readonly BlockStatementExtractor _extractor; + + public BlockScriptParser() : this(null) { } + + public BlockScriptParser(BuiltinFunctionRegistry? functionRegistry) + { + _extractor = new BlockStatementExtractor(functionRegistry); + } /// /// Parses a block-based script from source code diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockStatementExtractor.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockStatementExtractor.cs index e9465608..44706bb6 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockStatementExtractor.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockStatementExtractor.cs @@ -18,6 +18,12 @@ namespace KitX.Core.Workflow.BlockScripting; /// internal class BlockStatementExtractor { + private readonly BuiltinFunctionRegistry? _functionRegistry; + + public BlockStatementExtractor(BuiltinFunctionRegistry? functionRegistry = null) + { + _functionRegistry = functionRegistry; + } /// /// Creates a BlockDefinition from a recognized block and its validation result /// @@ -218,6 +224,20 @@ private void ExtractStatements(SyntaxNode root, BlockDefinition block, BlockType { block.Statements.Add(CreateFlowControlStatement(invoke, FlowControlType.LoopBodyEnd, exprStmt.GetLineNumber(), exprText)); } + // Try BuiltinFunctionRegistry for new/future functions (e.g. Flip) + else if (_functionRegistry != null && _functionRegistry.Get(methodName) is { } funcDef) + { + var stmt = funcDef.ExtractStatement(invoke, exprStmt.GetLineNumber(), exprText); + if (stmt != null) + block.Statements.Add(stmt); + else + block.Statements.Add(new ExpressionStatement + { + LineNumber = exprStmt.GetLineNumber(), + SourceCode = exprText, + Expression = exprText + }); + } else { block.Statements.Add(new ExpressionStatement @@ -248,6 +268,20 @@ private void ExtractStatements(SyntaxNode root, BlockDefinition block, BlockType { block.Statements.Add(CreateFlowControlStatement(assignInvoke, FlowControlType.LoopBodyEnd, exprStmt.GetLineNumber(), exprText)); } + // Try BuiltinFunctionRegistry for new/future functions (e.g. Flip) + else if (_functionRegistry != null && _functionRegistry.Get(methodName) is { } funcDef) + { + var stmt = funcDef.ExtractStatement(assignInvoke, exprStmt.GetLineNumber(), exprText); + if (stmt != null) + block.Statements.Add(stmt); + else + block.Statements.Add(new ExpressionStatement + { + LineNumber = exprStmt.GetLineNumber(), + SourceCode = exprText, + Expression = exprText + }); + } else { Log.Debug("[BlockStatementExtractor] Unknown methodName '{MethodName}', treating as ExpressionStatement", methodName); diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctionRegistry.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctionRegistry.cs new file mode 100644 index 00000000..53d052be --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctionRegistry.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using KitX.Core.Contract.Workflow; +using Serilog; + +namespace KitX.Core.Workflow.BlockScripting; + +/// +/// 内置函数定义的中央注册中心。通过反射自动发现所有 +/// 实现类并注册。替代 ExprUtils 中的散弹式 HashSet 和各管线类中的 switch/if-chain。 +/// +public class BuiltinFunctionRegistry +{ + private readonly Dictionary _functions = new(); + + /// 注册一个函数定义 + public void Register(IBuiltinFunctionDefinition definition) + { + _functions[definition.FunctionName] = definition; + Log.Debug("[BuiltinFunctionRegistry] Registered: {Name} (FlowControl={FC}, NonExtractable={NE})", + definition.FunctionName, definition.IsFlowControl, definition.IsNonExtractable); + } + + /// 按函数名查找定义。未找到返回 null。 + public IBuiltinFunctionDefinition? Get(string functionName) + => _functions.TryGetValue(functionName, out var def) ? def : null; + + /// 所有已注册的函数名集合 + public HashSet AllFunctionNames => _functions.Keys.ToHashSet(); + + /// 分类为 NonExtractable 的函数名集合 + public HashSet NonExtractableNames => + _functions.Values.Where(f => f.IsNonExtractable).Select(f => f.FunctionName).ToHashSet(); + + /// 分类为 FlowControl 的函数名集合 + public HashSet FlowControlNames => + _functions.Values.Where(f => f.IsFlowControl).Select(f => f.FunctionName).ToHashSet(); + + /// 所有已注册的定义 + public IReadOnlyCollection AllDefinitions => _functions.Values; + + /// + /// 从指定程序集中反射扫描所有 实现类, + /// 实例化并注册。应在 DI 启动时调用一次。 + /// + public static BuiltinFunctionRegistry Discover(params Assembly[] assemblies) + { + var registry = new BuiltinFunctionRegistry(); + var ifaceType = typeof(IBuiltinFunctionDefinition); + + foreach (var asm in assemblies) + { + Type[] types; + try + { + types = asm.GetTypes(); + } + catch (ReflectionTypeLoadException ex) + { + types = ex.Types.Where(t => t != null).ToArray()!; + } + + foreach (var type in types) + { + if (ifaceType.IsAssignableFrom(type) && !type.IsAbstract && !type.IsInterface) + { + try + { + var instance = (IBuiltinFunctionDefinition)Activator.CreateInstance(type)!; + registry.Register(instance); + } + catch (Exception ex) + { + Log.Warning(ex, "[BuiltinFunctionRegistry] Failed to instantiate {Type}", type.Name); + } + } + } + } + + Log.Information("[BuiltinFunctionRegistry] Discovered {Count} builtin functions: {Names}", + registry._functions.Count, string.Join(", ", registry._functions.Keys)); + return registry; + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/BranchFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/BranchFunction.cs new file mode 100644 index 00000000..eafa5b20 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/BranchFunction.cs @@ -0,0 +1,99 @@ +using System.Collections.Generic; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using KitX.Core.Contract.Workflow; +using KitX.Core.Workflow.Blueprint; +using KitX.Core.Workflow.Blueprint.Pipeline; + +using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Pins; + +namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions; + +/// +/// Branch 内置函数 — 条件分支控制流。 +/// +public class BranchFunction : IBuiltinFunctionDefinition +{ + public string FunctionName => "Branch"; + public string DisplayName => "Branch"; + public bool IsFlowControl => true; + public bool IsNonExtractable => false; + public BlueprintNodeType? LegacyNodeType => BlueprintNodeType.Branch; + public bool IsBlockTerminator => true; + public double NodeWidth => 120; + public double NodeHeight => 80; + + public IReadOnlyList InputPins => [ + new("Exec", PinType.Execution, 30), + new("Condition", PinType.Boolean, 50) + ]; + + public IReadOnlyList OutputPins => [ + new("True", PinType.Execution, 30), + new("False", PinType.Execution, 50) + ]; + + public string? GetExecutionMethodBody() => null; + + public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) + { + var args = invoke.ArgumentList.Arguments; + var stmt = new FlowControlStatement + { + LineNumber = lineNumber, + SourceCode = exprText ?? invoke.ToFullString(), + ControlType = FlowControlType.Branch + }; + if (args.Count >= 1) stmt.ConditionExpression = args[0].Expression.ToString(); + if (args.Count >= 2) stmt.TrueBlockName = GetStringLiteral(args[1].Expression); + if (args.Count >= 3) stmt.FalseBlockName = GetStringLiteral(args[2].Expression); + return stmt; + } + + public List FormatInvocation( + InvocationExpressionSyntax invoke, string blockName, + PipelineContext context, string? assignedVar) + { + // Branch is handled through FormatFlowControl, not FormatInvocation. + return []; + } + + public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) => node; + + public void OnNodeCreated(BlueprintNode node, FormattedStatement stmt, PipelineContext context) + { + if (!string.IsNullOrEmpty(stmt.TrueBlockName) || !string.IsNullOrEmpty(stmt.FalseBlockName)) + { + var arms = new List<(string PinName, string TargetBlockName)>(); + if (!string.IsNullOrEmpty(stmt.TrueBlockName)) + arms.Add(("True", stmt.TrueBlockName)); + if (!string.IsNullOrEmpty(stmt.FalseBlockName)) + arms.Add(("False", stmt.FalseBlockName)); + context.DeferredEdges.Add(new DeferredControlFlowEdge + { + SourceStatementId = stmt.StatementId, + Arms = arms, + }); + } + } + + public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) + { + var condition = helper.GetInputValue(node, "Condition"); + return new FlowControlStatement + { + ControlType = FlowControlType.Branch, + ConditionExpression = condition, + SourceCode = $"Branch({condition}, \"\", \"\");", + LineNumber = 1 + }; + } + + public IEnumerable GetOutputArms() => + [ + new() { PinName = "True", IsLoopback = false }, + new() { PinName = "False", IsLoopback = false } + ]; + + private static string GetStringLiteral(ExpressionSyntax expr) => + expr is LiteralExpressionSyntax lit ? lit.Token.ValueText : expr.ToString().Trim('"'); +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/BreakFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/BreakFunction.cs new file mode 100644 index 00000000..c06484f5 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/BreakFunction.cs @@ -0,0 +1,59 @@ +using System.Collections.Generic; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using KitX.Core.Contract.Workflow; +using KitX.Core.Workflow.Blueprint; +using KitX.Core.Workflow.Blueprint.Pipeline; + +namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions; + +/// +/// Break 内置函数 — 退出当前循环。 +/// +public class BreakFunction : IBuiltinFunctionDefinition +{ + public string FunctionName => "Break"; + public string DisplayName => "Break"; + public bool IsFlowControl => true; + public bool IsNonExtractable => true; + public BlueprintNodeType? LegacyNodeType => BlueprintNodeType.Break; + public double NodeWidth => 100; + public double NodeHeight => 40; + + public IReadOnlyList InputPins => [ + new("Exec", PinType.Execution, 20) + ]; + + public IReadOnlyList OutputPins => []; + + public string? GetExecutionMethodBody() => null; + + public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; + + public List FormatInvocation( + InvocationExpressionSyntax invoke, string blockName, + PipelineContext context, string? assignedVar) + { + return [new FormattedStatement + { + BlockName = blockName, + Kind = FormattedStatementKind.Break, + FunctionName = FunctionName, + OriginalExpression = invoke.ToString(), + SourceLine = 0, + }]; + } + + public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) => node; + + public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) + { + return new FlowControlStatement + { + ControlType = FlowControlType.Break, + SourceCode = "Break();", + LineNumber = 1 + }; + } + + public IEnumerable GetOutputArms() => []; +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/FlipFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/FlipFunction.cs new file mode 100644 index 00000000..d2d3ba6d --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/FlipFunction.cs @@ -0,0 +1,116 @@ +using System.Collections.Generic; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using KitX.Core.Contract.Workflow; +using KitX.Core.Workflow.Blueprint; +using KitX.Core.Workflow.Blueprint.Pipeline; + +namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions; + +/// +/// Flip 内置函数 — 交替路由控制流。每次执行时交替选择两个输出分支之一。 +/// 第一次执行走 A(奇数次),第二次走 B(偶数次),循环往复。 +/// 运行级状态:从 Entry 节点重新开始运行时重置计数器。 +/// +/// BlockScript 语法:Flip("blockA", "blockB") +/// +public class FlipFunction : IBuiltinFunctionDefinition +{ + public string FunctionName => "Flip"; + public string DisplayName => "Flip"; + public bool IsFlowControl => true; + public bool IsNonExtractable => true; + public bool IsBlockTerminator => true; + public double NodeWidth => 120; + public double NodeHeight => 80; + + public IReadOnlyList InputPins => [ + new("Exec", PinType.Execution, 30) + ]; + + public IReadOnlyList OutputPins => [ + new("A", PinType.Execution, 30), + new("B", PinType.Execution, 50) + ]; + + public string? GetExecutionMethodBody() => """ + private int _flipCounter = 0; + + public void ResetRunState() => _flipCounter = 0; + + public string? Flip(string outputA, string outputB) + { + _flipCounter++; + NextBlock = (_flipCounter % 2 == 1) ? outputA : outputB; + return NextBlock; + } + """; + + public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) + { + var args = invoke.ArgumentList.Arguments; + var stmt = new FlowControlStatement + { + LineNumber = lineNumber, + SourceCode = exprText ?? invoke.ToFullString(), + ControlType = FlowControlType.Branch // Reuse Branch type for cross-block routing + }; + if (args.Count >= 1) stmt.TrueBlockName = GetStringLiteral(args[0].Expression); + if (args.Count >= 2) stmt.FalseBlockName = GetStringLiteral(args[1].Expression); + return stmt; + } + + public List FormatInvocation( + InvocationExpressionSyntax invoke, string blockName, + PipelineContext context, string? assignedVar) + { + var args = invoke.ArgumentList.Arguments; + var trueBlock = args.Count >= 1 ? GetStringLiteral(args[0].Expression) : ""; + var falseBlock = args.Count >= 2 ? GetStringLiteral(args[1].Expression) : ""; + + return [new FormattedStatement + { + BlockName = blockName, + Kind = FormattedStatementKind.Branch, // Reuse Branch kind for pipeline routing + FunctionName = FunctionName, + TrueBlockName = trueBlock, + FalseBlockName = falseBlock, + OriginalExpression = invoke.ToString(), + SourceLine = 0, + }]; + } + + public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) => node; + + public void OnNodeCreated(BlueprintNode node, FormattedStatement stmt, PipelineContext context) + { + var arms = new List<(string PinName, string TargetBlockName)>(); + if (!string.IsNullOrEmpty(stmt.TrueBlockName)) + arms.Add(("A", stmt.TrueBlockName)); + if (!string.IsNullOrEmpty(stmt.FalseBlockName)) + arms.Add(("B", stmt.FalseBlockName)); + context.DeferredEdges.Add(new DeferredControlFlowEdge + { + SourceStatementId = stmt.StatementId, + Arms = arms, + }); + } + + public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) + { + return new ExpressionStatement + { + Expression = "Flip(\"\", \"\")", + SourceCode = "Flip(\"\", \"\");", + LineNumber = 1 + }; + } + + public IEnumerable GetOutputArms() => + [ + new() { PinName = "A", IsLoopback = false }, + new() { PinName = "B", IsLoopback = false } + ]; + + private static string GetStringLiteral(ExpressionSyntax expr) => + expr is LiteralExpressionSyntax lit ? lit.Token.ValueText : expr.ToString().Trim('"'); +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/GetFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/GetFunction.cs new file mode 100644 index 00000000..ddad945e --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/GetFunction.cs @@ -0,0 +1,99 @@ +using System.Collections.Generic; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using KitX.Core.Contract.Workflow; +using KitX.Core.Workflow.Blueprint; +using KitX.Core.Workflow.Blueprint.Pipeline; + +namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions; + +/// +/// Get 内置函数 — 读取变量值。 +/// +public class GetFunction : IBuiltinFunctionDefinition +{ + public string FunctionName => "Get"; + public string DisplayName => "Get"; + public bool IsFlowControl => false; + public bool IsNonExtractable => true; + public BlueprintNodeType? LegacyNodeType => BlueprintNodeType.Get; + public double NodeWidth => 120; + public double NodeHeight => 60; + + public IReadOnlyList InputPins => [ + new("Exec", PinType.Execution, 20) + ]; + + public IReadOnlyList OutputPins => [ + new("Exec", PinType.Execution, 20), + new("Value", PinType.Any, 40) + ]; + + public string? GetExecutionMethodBody() => null; + + public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; + + public List FormatInvocation( + InvocationExpressionSyntax invoke, string blockName, + PipelineContext context, string? assignedVar) + { + var args = invoke.ArgumentList.Arguments.Select(a => a.Expression.ToString()).ToList(); + + // Extract varName from first argument + string? getVarName = null; + if (invoke.ArgumentList.Arguments.Count > 0) + { + var firstArgExpr = invoke.ArgumentList.Arguments[0].Expression; + getVarName = ExprUtils.GetStringLiteralValue(firstArgExpr) ?? args[0]; + } + + // Auto-generate PubVar if not already assigned + string? pubVarTarget; + if (string.IsNullOrEmpty(assignedVar) || !context.PubVarNames.Contains(assignedVar)) + { + pubVarTarget = ExprUtils.GeneratePubVarName(context.NextPubVarCounter++); + if (!context.PubVarNames.Contains(pubVarTarget)) + context.PubVarNames.Add(pubVarTarget); + } + else + { + pubVarTarget = assignedVar; + } + + return [new FormattedStatement + { + BlockName = blockName, + Kind = FormattedStatementKind.Assignment, + FunctionName = FunctionName, + PubVarTarget = pubVarTarget, + GetVarName = getVarName, + Arguments = args, + OriginalExpression = invoke.ToString(), + SourceLine = 0, + }]; + } + + public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) + { + if (node is BuiltinFunctionNode bfn) + bfn.Properties["VarName"] = stmt.GetVarName ?? ""; + return node; + } + + public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) + { + var varName = node switch + { + GetNode gn => gn.VarName, + BuiltinFunctionNode bfn => bfn.Properties.GetValueOrDefault("VarName", ""), + _ => "" + }; + return new ExpressionStatement + { + Expression = $"Get(\"{varName}\")", + SourceCode = $"Get(\"{varName}\");", + LineNumber = 1 + }; + } + + public IEnumerable GetOutputArms() => []; +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/LoopBodyEndFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/LoopBodyEndFunction.cs new file mode 100644 index 00000000..ca96c2f0 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/LoopBodyEndFunction.cs @@ -0,0 +1,80 @@ +using System.Collections.Generic; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using KitX.Core.Contract.Workflow; +using KitX.Core.Workflow.Blueprint; +using KitX.Core.Workflow.Blueprint.Pipeline; + +namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions; + +/// +/// LoopBodyEnd 内置函数 — 标记循环体的结尾并返回循环条件块。 +/// BlockScript 语法:LoopBodyEnd("parentBlockName") +/// +public class LoopBodyEndFunction : IBuiltinFunctionDefinition +{ + public string FunctionName => "LoopBodyEnd"; + public string DisplayName => "LoopBodyEnd"; + public bool IsFlowControl => true; + public bool IsNonExtractable => false; + public bool IsBlockTerminator => true; + public double NodeWidth => 80; + public double NodeHeight => 60; + + public IReadOnlyList InputPins => [ + new("Exec", PinType.Execution, 30) + ]; + + public IReadOnlyList OutputPins => []; + + public string? GetExecutionMethodBody() => """ + public string? LoopBodyEnd(string parentBlockName) + { + NextBlock = $"{parentBlockName}_Loop"; + return NextBlock; + } + """; + + public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) + { + var args = invoke.ArgumentList.Arguments; + var stmt = new FlowControlStatement + { + LineNumber = lineNumber, + SourceCode = exprText ?? invoke.ToFullString(), + ControlType = FlowControlType.LoopBodyEnd + }; + if (args.Count >= 1) stmt.LoopBodyEndReturnTo = GetStringLiteral(args[0].Expression); + return stmt; + } + + public List FormatInvocation( + InvocationExpressionSyntax invoke, string blockName, + PipelineContext context, string? assignedVar) + { + // LoopBodyEnd is handled through FormatFlowControl, not FormatInvocation. + return []; + } + + public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) => node; + + public void OnNodeCreated(BlueprintNode node, FormattedStatement stmt, PipelineContext context) + { + // LoopBodyEnd does not create a node — handled by ProcessStatement's special case. + // No additional OnNodeCreated logic needed. + } + + public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) + { + return new FlowControlStatement + { + ControlType = FlowControlType.LoopBodyEnd, + SourceCode = "LoopBodyEnd(\"\");", + LineNumber = 1 + }; + } + + public IEnumerable GetOutputArms() => []; + + private static string GetStringLiteral(ExpressionSyntax expr) => + expr is LiteralExpressionSyntax lit ? lit.Token.ValueText : expr.ToString().Trim('"'); +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/LoopFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/LoopFunction.cs new file mode 100644 index 00000000..c7917556 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/LoopFunction.cs @@ -0,0 +1,108 @@ +using System.Collections.Generic; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using KitX.Core.Contract.Workflow; +using KitX.Core.Workflow.Blueprint; +using KitX.Core.Workflow.Blueprint.Pipeline; + +using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Pins; + +namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions; + +/// +/// Loop 内置函数 — 循环控制流。 +/// BlockScript 语法:Loop(condition, "loopBodyBlock", "loopEndBlock") +/// +public class LoopFunction : IBuiltinFunctionDefinition +{ + public string FunctionName => "Loop"; + public string DisplayName => "Loop"; + public bool IsFlowControl => true; + public bool IsNonExtractable => false; + public BlueprintNodeType? LegacyNodeType => BlueprintNodeType.Loop; + public bool IsBlockTerminator => true; + public double NodeWidth => 120; + public double NodeHeight => 80; + + public IReadOnlyList InputPins => [ + new("Exec", PinType.Execution, 30), + new("Condition", PinType.Boolean, 50) + ]; + + public IReadOnlyList OutputPins => [ + new("LoopBody", PinType.Execution, 30), + new("LoopEnd", PinType.Execution, 50) + ]; + + public string? GetExecutionMethodBody() => null; + + public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) + { + var args = invoke.ArgumentList.Arguments; + var stmt = new FlowControlStatement + { + LineNumber = lineNumber, + SourceCode = exprText ?? invoke.ToFullString(), + ControlType = FlowControlType.Loop + }; + if (args.Count >= 1) stmt.ConditionExpression = args[0].Expression.ToString(); + if (args.Count >= 2) stmt.TrueBlockName = GetStringLiteral(args[1].Expression); + if (args.Count >= 3) stmt.FalseBlockName = GetStringLiteral(args[2].Expression); + return stmt; + } + + public List FormatInvocation( + InvocationExpressionSyntax invoke, string blockName, + PipelineContext context, string? assignedVar) + { + // Loop is handled through FormatFlowControl, not FormatInvocation. + return []; + } + + public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) => node; + + public void OnNodeCreated(BlueprintNode node, FormattedStatement stmt, PipelineContext context) + { + // Record generic deferred edges for LoopBody/LoopEnd arms + var arms = new List<(string PinName, string TargetBlockName)>(); + if (!string.IsNullOrEmpty(stmt.TrueBlockName)) + arms.Add(("LoopBody", stmt.TrueBlockName)); + if (!string.IsNullOrEmpty(stmt.FalseBlockName)) + arms.Add(("LoopEnd", stmt.FalseBlockName)); + + if (arms.Count > 0) + { + context.DeferredEdges.Add(new DeferredControlFlowEdge + { + SourceStatementId = stmt.StatementId, + Arms = arms, + }); + } + + // Also record into LoopDefs for condition duplication support + context.LoopDefs.Add((stmt.StatementId, stmt.TrueBlockName, stmt.FalseBlockName, stmt.BlockName)); + + // Store for LoopBodyEnd loopback resolution + context.LoopNodesByParent[stmt.BlockName] = node; + } + + public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) + { + var condition = helper.GetInputValue(node, "Condition"); + return new FlowControlStatement + { + ControlType = FlowControlType.Loop, + ConditionExpression = condition, + SourceCode = $"Loop({condition}, \"\", \"\");", + LineNumber = 1 + }; + } + + public IEnumerable GetOutputArms() => + [ + new() { PinName = "LoopBody", IsLoopback = false }, + new() { PinName = "LoopEnd", IsLoopback = false } + ]; + + private static string GetStringLiteral(ExpressionSyntax expr) => + expr is LiteralExpressionSyntax lit ? lit.Token.ValueText : expr.ToString().Trim('"'); +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PauseFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PauseFunction.cs new file mode 100644 index 00000000..a954836c --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PauseFunction.cs @@ -0,0 +1,65 @@ +using System.Collections.Generic; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using KitX.Core.Contract.Workflow; +using KitX.Core.Workflow.Blueprint; +using KitX.Core.Workflow.Blueprint.Pipeline; + +namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions; + +/// +/// Pause 内置函数 — 暂停执行指定毫秒数。 +/// +public class PauseFunction : IBuiltinFunctionDefinition +{ + public string FunctionName => "Pause"; + public string DisplayName => "Pause"; + public bool IsFlowControl => false; + public bool IsNonExtractable => true; + public BlueprintNodeType? LegacyNodeType => BlueprintNodeType.Pause; + public double NodeWidth => 100; + public double NodeHeight => 50; + + public IReadOnlyList InputPins => [ + new("Exec", PinType.Execution, 20), + new("Milliseconds", PinType.Integer, 35) + ]; + + public IReadOnlyList OutputPins => [ + new("Exec", PinType.Execution, 25) + ]; + + public string? GetExecutionMethodBody() => null; + + public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; + + public List FormatInvocation( + InvocationExpressionSyntax invoke, string blockName, + PipelineContext context, string? assignedVar) + { + var args = invoke.ArgumentList.Arguments.Select(a => a.Expression.ToString()).ToList(); + return [new FormattedStatement + { + BlockName = blockName, + Kind = FormattedStatementKind.Pause, + FunctionName = FunctionName, + Arguments = args, + OriginalExpression = invoke.ToString(), + SourceLine = 0, + }]; + } + + public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) => node; + + public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) + { + var ms = helper.GetInputValue(node, "Milliseconds"); + return new ExpressionStatement + { + Expression = $"{FunctionName}({ms})", + SourceCode = $"{FunctionName}({ms});", + LineNumber = 1 + }; + } + + public IEnumerable GetOutputArms() => []; +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PrintFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PrintFunction.cs new file mode 100644 index 00000000..7f306769 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PrintFunction.cs @@ -0,0 +1,65 @@ +using System.Collections.Generic; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using KitX.Core.Contract.Workflow; +using KitX.Core.Workflow.Blueprint; +using KitX.Core.Workflow.Blueprint.Pipeline; + +namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions; + +/// +/// Print 内置函数 — 输出值到控制台。 +/// +public class PrintFunction : IBuiltinFunctionDefinition +{ + public string FunctionName => "Print"; + public string DisplayName => "Print"; + public bool IsFlowControl => false; + public bool IsNonExtractable => true; + public BlueprintNodeType? LegacyNodeType => BlueprintNodeType.Print; + public double NodeWidth => 100; + public double NodeHeight => 50; + + public IReadOnlyList InputPins => [ + new("Exec", PinType.Execution, 20), + new("Value", PinType.Any, 35) + ]; + + public IReadOnlyList OutputPins => [ + new("Exec", PinType.Execution, 25) + ]; + + public string? GetExecutionMethodBody() => null; // 已在 BlockScriptExecutionGlobals 中手工实现 + + public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; + + public List FormatInvocation( + InvocationExpressionSyntax invoke, string blockName, + PipelineContext context, string? assignedVar) + { + var args = invoke.ArgumentList.Arguments.Select(a => a.Expression.ToString()).ToList(); + return [new FormattedStatement + { + BlockName = blockName, + Kind = FormattedStatementKind.Print, + FunctionName = FunctionName, + Arguments = args, + OriginalExpression = invoke.ToString(), + SourceLine = 0, + }]; + } + + public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) => node; + + public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) + { + var value = helper.GetInputValue(node, "Value"); + return new ExpressionStatement + { + Expression = $"{FunctionName}({value})", + SourceCode = $"{FunctionName}({value});", + LineNumber = 1 + }; + } + + public IEnumerable GetOutputArms() => []; +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/SetFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/SetFunction.cs new file mode 100644 index 00000000..2bc47cb2 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/SetFunction.cs @@ -0,0 +1,88 @@ +using System.Collections.Generic; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using KitX.Core.Contract.Workflow; +using KitX.Core.Workflow.Blueprint; +using KitX.Core.Workflow.Blueprint.Pipeline; + +namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions; + +/// +/// Set 内置函数 — 设置变量值。 +/// +public class SetFunction : IBuiltinFunctionDefinition +{ + public string FunctionName => "Set"; + public string DisplayName => "Set"; + public bool IsFlowControl => false; + public bool IsNonExtractable => true; + public BlueprintNodeType? LegacyNodeType => BlueprintNodeType.Set; + public double NodeWidth => 120; + public double NodeHeight => 60; + + public IReadOnlyList InputPins => [ + new("Exec", PinType.Execution, 20), + new("Value", PinType.Any, 40) + ]; + + public IReadOnlyList OutputPins => [ + new("Exec", PinType.Execution, 20) + ]; + + public string? GetExecutionMethodBody() => null; + + public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; + + public List FormatInvocation( + InvocationExpressionSyntax invoke, string blockName, + PipelineContext context, string? assignedVar) + { + var currentArgExprs = invoke.ArgumentList.Arguments.Select(a => a.Expression.ToString()).ToList(); + + // First arg is varName (string literal) — extract it + string? setVarName = null; + if (invoke.ArgumentList.Arguments.Count > 0) + { + var firstArgExpr = invoke.ArgumentList.Arguments[0].Expression; + var varNameLiteral = ExprUtils.GetStringLiteralValue(firstArgExpr); + setVarName = varNameLiteral ?? currentArgExprs[0]; + currentArgExprs.RemoveAt(0); + } + + return [new FormattedStatement + { + BlockName = blockName, + Kind = FormattedStatementKind.Set, + FunctionName = FunctionName, + SetVarName = setVarName, + Arguments = currentArgExprs, + OriginalExpression = invoke.ToString(), + SourceLine = 0, + }]; + } + + public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) + { + if (node is BuiltinFunctionNode bfn) + bfn.Properties["VarName"] = stmt.SetVarName ?? ""; + return node; + } + + public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) + { + var varName = node switch + { + SetNode sn => sn.VarName, + BuiltinFunctionNode bfn => bfn.Properties.GetValueOrDefault("VarName", ""), + _ => "" + }; + var value = helper.GetInputValue(node, "Value"); + return new ExpressionStatement + { + Expression = $"{FunctionName}(\"{varName}\", {value})", + SourceCode = $"{FunctionName}(\"{varName}\", {value});", + LineNumber = 1 + }; + } + + public IEnumerable GetOutputArms() => []; +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/IBuiltinFunctionDefinition.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/IBuiltinFunctionDefinition.cs new file mode 100644 index 00000000..85b5f773 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/IBuiltinFunctionDefinition.cs @@ -0,0 +1,115 @@ +using System.Collections.Generic; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using KitX.Core.Contract.Workflow; +using KitX.Core.Workflow.Blueprint; +using KitX.Core.Workflow.Blueprint.Pipeline; + +namespace KitX.Core.Workflow.BlockScripting; + +/// +/// 自描述的内置函数定义接口。实现此接口的类会被 +/// 通过反射自动发现并注册。新内置函数只需创建一个实现类即可,无需修改其他文件。 +/// +public interface IBuiltinFunctionDefinition +{ + // ─── 身份 ─────────────────────────────────────── + + /// BlockScript 源码中的函数名(如 "Print"、"Branch") + string FunctionName { get; } + + /// 蓝图编辑器中显示的名称 + string DisplayName { get; } + + // ─── 分类 ─────────────────────────────────────── + + /// 是否为控制流函数(如 Branch/Loop)。影响 ScriptFormatter 的展开策略和 NodeBuilder 的跨块边解析。 + bool IsFlowControl { get; } + + /// + /// ScriptFormatter 是否应将其保持内联(不展开嵌套调用)。 + /// 如 Set、Print、Pause 等直接执行副作用的函数应标记为 true。 + /// + bool IsNonExtractable { get; } + + // ─── 节点布局 ─────────────────────────────────── + + /// 蓝图节点宽度 + double NodeWidth { get; } + + /// 蓝图节点高度 + double NodeHeight { get; } + + /// 输入引脚描述 + IReadOnlyList InputPins { get; } + + /// 输出引脚描述 + IReadOnlyList OutputPins { get; } + + // ─── 执行 ─────────────────────────────────────── + + /// + /// 返回完整的方法签名+方法体字符串,用于编译到 CSharpScript 执行上下文。 + /// 返回 null 表示执行完全通过语句管线处理(如简单表达式函数)。 + /// + string? GetExecutionMethodBody(); + + // ─── 解析(BlockScript → AST)───────────────── + + /// + /// 从 Roslyn InvocationExpressionSyntax 提取语句。 + /// 返回 null 表示使用默认 ExpressionStatement 处理。 + /// + BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText); + + // ─── 格式化(AST → FormattedStatement)───────── + + /// + /// 将函数调用格式化为展开后的 FormattedStatement 列表。 + /// 嵌套参数应在此时被展开为 PubVar 赋值。 + /// + List FormatInvocation( + InvocationExpressionSyntax invoke, + string blockName, + PipelineContext context, + string? assignedVar); + + // ─── 节点构建(FormattedStatement → BlueprintNode)── + + /// + /// 对新创建的 BuiltinFunctionNode 进行额外配置(如设置 Properties 字典)。 + /// 返回配置后的节点。 + /// + BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt); + + // ─── 导出(Blueprint → BlockScript)──────────── + + /// 将蓝图节点转换回 BlockScript 语句 + BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper); + + /// 返回控制流输出臂描述。非控制流函数返回空集合。 + IEnumerable GetOutputArms(); + + // ─── 向后兼容 ────────────────────────────────────── + + /// + /// 对应的旧版 BlueprintNodeType。用于反向转换时将导出策略映射到正确的节点类型。 + /// 标准函数(Print/Set/Get/Branch/Loop/Break/Pause)返回对应的枚举值; + /// 纯新增函数(如 Flip)返回 null。 + /// + BlueprintNodeType? LegacyNodeType => null; + + // ─── 控制流(可选,默认实现为无操作)────────────── + + /// + /// 是否终止当前块(如 Branch/Loop/Flip 执行后不应继续顺序执行)。 + /// 默认 false。设为 true 会使 NodeBuilder 标记 blockEndsWithFlowCtrl。 + /// + bool IsBlockTerminator => false; + + /// + /// 控制流函数:节点创建后的后处理(如记录延迟边定义到 context.DeferredEdges)。 + /// 仅在 IsFlowControl == true 且 IsBlockTerminator == true 时被调用。 + /// 默认无操作。 + /// + void OnNodeCreated(BlueprintNode node, FormattedStatement stmt, PipelineContext context) { } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlockScriptToBlueprintConverter.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlockScriptToBlueprintConverter.cs index e2fc91e3..149c8c05 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlockScriptToBlueprintConverter.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlockScriptToBlueprintConverter.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using KitX.Core.Contract.Workflow; +using KitX.Core.Workflow.BlockScripting; using KitX.Core.Workflow.Blueprint.Pipeline; using Serilog; @@ -16,6 +17,7 @@ public class BlockScriptToBlueprintConverter : IBlockScriptToBlueprintConverter private readonly IBlockScriptParser _parser; private readonly INodeRegistry _nodeRegistry; private readonly ILayoutService _layoutService; + private readonly BuiltinFunctionRegistry? _functionRegistry; /// /// The pipeline context from the last conversion (for debug inspection). @@ -32,6 +34,15 @@ public BlockScriptToBlueprintConverter( _layoutService = layoutService; } + public BlockScriptToBlueprintConverter( + IBlockScriptParser parser, + INodeRegistry nodeRegistry, + ILayoutService layoutService, + BuiltinFunctionRegistry functionRegistry) : this(parser, nodeRegistry, layoutService) + { + _functionRegistry = functionRegistry; + } + public Contract.Workflow.Blueprint Convert(string sourceCode, List? helperFunctions = null) { var result = _parser.Parse(sourceCode); @@ -60,14 +71,14 @@ public Contract.Workflow.Blueprint Convert(BlockScript script) context.ConstNodes.Count, context.PubVarNames.Count); // ── Phase 2: Script formatting (expand nested calls + loop condition duplication) ── - var formatter = new ScriptFormatter(helpers); + var formatter = new ScriptFormatter(helpers, _functionRegistry); context.FormattedScript = formatter.Format(script, context); Log.Debug("[Converter] Phase 2: {BlockCount} blocks, {StmtCount} statements", context.FormattedScript.Blocks.Count, context.FormattedScript.Blocks.Sum(b => b.Statements.Count)); // ── Phase 3: Node creation + exec edges + PubVar reuse ── - var nodeBuilder = new NodeBuilder(_nodeRegistry, helpers); + var nodeBuilder = new NodeBuilder(_nodeRegistry, helpers, _functionRegistry); nodeBuilder.Build(context.FormattedScript, context); Log.Debug("[Converter] Phase 3: {NodeCount} nodes, {ExecEdgeCount} exec edges", context.AllNodes.Count, context.ExecEdges.Count); diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintToBlockScriptConverter.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintToBlockScriptConverter.cs index fa7f6469..fbbd8997 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintToBlockScriptConverter.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintToBlockScriptConverter.cs @@ -23,8 +23,25 @@ public class BlueprintToBlockScriptConverter : IBlueprintToBlockScriptConverter public BlueprintToBlockScriptConverter(IEnumerable strategies) { - var strategyMap = strategies.ToDictionary(s => s.NodeType); - Walker = new ExecutionFlowWalker(strategyMap, _exportHelper); + var strategyMap = new Dictionary(); + var builtinMap = new Dictionary(); + + foreach (var s in strategies) + { + if (s is BuiltinFunctionExportStrategyAdapter adapter) + { + builtinMap[adapter.FunctionName] = s; + // Standard functions also register by their legacy NodeType for reverse conversion + if (adapter.LegacyNodeType != null) + strategyMap[adapter.LegacyNodeType.Value] = s; + } + else + { + strategyMap[s.NodeType] = s; + } + } + + Walker = new ExecutionFlowWalker(strategyMap, builtinMap, _exportHelper); } /// Execution flow walker — accessible for testing diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BuiltinFunctionExportStrategyAdapter.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BuiltinFunctionExportStrategyAdapter.cs new file mode 100644 index 00000000..8db119ad --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BuiltinFunctionExportStrategyAdapter.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; +using KitX.Core.Contract.Workflow; +using KitX.Core.Workflow.BlockScripting; + +namespace KitX.Core.Workflow.Blueprint; + +/// +/// 将 适配为 , +/// 消除了每个内置函数需要单独导出策略类的散弹式修改。 +/// +public class BuiltinFunctionExportStrategyAdapter : INodeExportStrategy +{ + private readonly IBuiltinFunctionDefinition _definition; + + public BuiltinFunctionExportStrategyAdapter(IBuiltinFunctionDefinition definition) + { + _definition = definition; + } + + /// 关联的函数名(用于调试和日志) + public string FunctionName => _definition.FunctionName; + + /// + public BlueprintNodeType NodeType => BlueprintNodeType.BuiltinFunction; + + /// 代理底层定义的 LegacyNodeType,用于反向映射时识别具体的蓝图节点类型。 + public BlueprintNodeType? LegacyNodeType => _definition.LegacyNodeType; + + /// + public bool IsControlFlow => _definition.IsFlowControl; + + /// + public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) + => _definition.ToStatement(node, helper); + + /// + public IEnumerable GetOutputArms(BlueprintNode node) + => _definition.GetOutputArms(); +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExecutionFlowWalker.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExecutionFlowWalker.cs index 5d356c7b..bc1c8e78 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExecutionFlowWalker.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExecutionFlowWalker.cs @@ -18,13 +18,16 @@ namespace KitX.Core.Workflow.Blueprint; internal class ExecutionFlowWalker { private readonly Dictionary _strategies; + private readonly Dictionary _builtinFunctionStrategies; private readonly NodeExportHelper _exportHelper; public ExecutionFlowWalker( Dictionary strategies, + Dictionary builtinFunctionStrategies, NodeExportHelper exportHelper) { _strategies = strategies; + _builtinFunctionStrategies = builtinFunctionStrategies; _exportHelper = exportHelper; } @@ -136,7 +139,7 @@ private void WalkNode(BlueprintNode node, BlockDefinition currentBlock, } } - if (node.NodeType is BlueprintNodeType.Branch or BlueprintNodeType.Loop) + if (IsBranchNode(node) || IsLoopNode(node)) { ctx.PendingControlFlowNodes.Add(node); return; @@ -201,8 +204,55 @@ private static FlowControlStatement CreateLoopBodyEndStatement(string? returnTo SourceCode = "Break();", LineNumber = 1 }; + case BlueprintNodeType.BuiltinFunction when IsBreakNode(node): + return new FlowControlStatement + { + ControlType = FlowControlType.Break, + SourceCode = "Break();", + LineNumber = 1 + }; case BlueprintNodeType.Get: return GenerateGetStatementViaStrategy(node, ctx); + case BlueprintNodeType.BuiltinFunction when IsGetNode(node): + return GenerateGetStatementViaStrategy(node, ctx); + case BlueprintNodeType.Call: + { + if (node is not CallNode call) return null; + var callArgs = _exportHelper.GetInputArgs(call); + var funcRef = string.IsNullOrEmpty(call.PluginName) + ? call.FunctionName : $"{call.PluginName}.{call.FunctionName}"; + var sourceCode = $"{funcRef}({callArgs})"; + var callStmt = new ExpressionStatement + { + Expression = sourceCode, + SourceCode = sourceCode + ";", + LineNumber = 1 + }; + PostProcessCallReturn(node, callStmt, ctx); + return callStmt; + } + case BlueprintNodeType.CallHelper: + { + if (node is not CallHelperNode callHelper) return null; + var helperArgs = _exportHelper.GetInputArgs(callHelper); + var expression = $"{callHelper.HelperFunctionName}({helperArgs})"; + var helperStmt = new ExpressionStatement + { + Expression = expression, + SourceCode = expression + ";", + LineNumber = 1 + }; + PostProcessCallReturn(node, helperStmt, ctx); + return helperStmt; + } + case BlueprintNodeType.BuiltinFunction: + { + // Look up strategy by function name (BuiltinFunction nodes share one NodeType) + if (node is BuiltinFunctionNode bfNode + && _builtinFunctionStrategies.TryGetValue(bfNode.FunctionName, out var bfStrategy)) + return bfStrategy.ToStatement(node, _exportHelper); + return null; + } default: break; } @@ -217,6 +267,16 @@ private static FlowControlStatement CreateLoopBodyEndStatement(string? returnTo if (stmt == null) return null; // Post-process: add PubVar prefix for Call/CallHelper with consumed Return + PostProcessCallReturn(node, stmt, ctx); + + return stmt; + } + + /// + /// Post-processes Call/CallHelper statements to add PubVar prefix when Return pin is consumed. + /// + private void PostProcessCallReturn(BlueprintNode node, BlockStatement stmt, ReverseConversionContext ctx) + { if (node.NodeType is BlueprintNodeType.Call or BlueprintNodeType.CallHelper && stmt is ExpressionStatement exprStmt) { @@ -229,8 +289,6 @@ private static FlowControlStatement CreateLoopBodyEndStatement(string? returnTo exprStmt.SourceCode = $"{pubVar} = {exprStmt.Expression};"; } } - - return stmt; } private BlockStatement? GenerateGetStatementViaStrategy(BlueprintNode node, ReverseConversionContext ctx) @@ -273,9 +331,11 @@ private void ProcessSubGraphs(ReverseConversionContext ctx) switch (node.NodeType) { case BlueprintNodeType.Branch: + case BlueprintNodeType.BuiltinFunction when IsBranchNode(node): ProcessBranchSubGraphs(node, ctx, processedTargets); break; case BlueprintNodeType.Loop: + case BlueprintNodeType.BuiltinFunction when IsLoopNode(node): ProcessLoopSubGraphs(node, ctx, processedTargets); break; } @@ -385,7 +445,7 @@ private void DetectAndInsertLoopBodyEnds(Contract.Workflow.Blueprint bp, Reverse foreach (var scope in bp.BlockScopes) { if (scope.NodeIds.Count == 0) continue; - if (scope.NodeIds.Any(id => ctx.NodeById.TryGetValue(id, out var n) && n.NodeType == BlueprintNodeType.Loop)) + if (scope.NodeIds.Any(id => ctx.NodeById.TryGetValue(id, out var n) && IsLoopNode(n))) continue; var lastNodeId = scope.NodeIds[scope.NodeIds.Count - 1]; @@ -421,7 +481,7 @@ private static (BlueprintNode? loopNode, List path) FollowExecCha { visited.Add(current.Id); - if (current.NodeType == BlueprintNodeType.Loop) + if (IsLoopNode(current)) return (current, path); var execOutPin = current.OutputPins.FirstOrDefault(p => p.Name == Exec); @@ -484,4 +544,26 @@ private void GenerateDataChainStatements(string sourceNodeId, } return null; } + + // ─── BuiltinFunction Node Type Helpers ───────────────────────────────── + + /// + /// Checks if a node represents the given logical type, accounting for both + /// legacy typed nodes (e.g. LoopNode) and registry-based BuiltinFunctionNode + /// with the equivalent function name. + /// + private static bool IsNodeType(BlueprintNode node, BlueprintNodeType type) => node.NodeType == type + || (node is BuiltinFunctionNode bfn && bfn.FunctionName == type.ToString()); + + private static bool IsBranchNode(BlueprintNode node) => + IsNodeType(node, BlueprintNodeType.Branch); + + private static bool IsLoopNode(BlueprintNode node) => + IsNodeType(node, BlueprintNodeType.Loop); + + private static bool IsBreakNode(BlueprintNode node) => + IsNodeType(node, BlueprintNodeType.Break); + + private static bool IsGetNode(BlueprintNode node) => + IsNodeType(node, BlueprintNodeType.Get); } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/BranchNodeExportStrategy.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/BranchNodeExportStrategy.cs deleted file mode 100644 index 753c25ef..00000000 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/BranchNodeExportStrategy.cs +++ /dev/null @@ -1,31 +0,0 @@ -using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.BlockScripting; - -namespace KitX.Core.Workflow.Blueprint.ExportStrategies; - -public class BranchNodeExportStrategy : INodeExportStrategy -{ - public BlueprintNodeType NodeType => BlueprintNodeType.Branch; - public bool IsControlFlow => true; - - public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) - { - var condition = helper.GetInputValue(node, BlockScriptWellKnown.Pins.Condition); - return new FlowControlStatement - { - ControlType = FlowControlType.Branch, - ConditionExpression = condition, - SourceCode = $"Branch({condition}, \"\", \"\");", - LineNumber = 1 - }; - } - - public IEnumerable GetOutputArms(BlueprintNode node) - { - return - [ - new OutputArmDescriptor { PinName = BlockScriptWellKnown.Pins.True, IsLoopback = false }, - new OutputArmDescriptor { PinName = BlockScriptWellKnown.Pins.False, IsLoopback = false } - ]; - } -} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/BreakNodeExportStrategy.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/BreakNodeExportStrategy.cs deleted file mode 100644 index e9eca4c7..00000000 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/BreakNodeExportStrategy.cs +++ /dev/null @@ -1,21 +0,0 @@ -using KitX.Core.Contract.Workflow; - -namespace KitX.Core.Workflow.Blueprint.ExportStrategies; - -public class BreakNodeExportStrategy : INodeExportStrategy -{ - public BlueprintNodeType NodeType => BlueprintNodeType.Break; - public bool IsControlFlow => false; - - public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) - { - return new FlowControlStatement - { - ControlType = FlowControlType.Break, - SourceCode = "Break();", - LineNumber = 1 - }; - } - - public IEnumerable GetOutputArms(BlueprintNode node) => []; -} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/CallHelperNodeExportStrategy.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/CallHelperNodeExportStrategy.cs deleted file mode 100644 index 97eed86b..00000000 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/CallHelperNodeExportStrategy.cs +++ /dev/null @@ -1,24 +0,0 @@ -using KitX.Core.Contract.Workflow; - -namespace KitX.Core.Workflow.Blueprint.ExportStrategies; - -public class CallHelperNodeExportStrategy : INodeExportStrategy -{ - public BlueprintNodeType NodeType => BlueprintNodeType.CallHelper; - public bool IsControlFlow => false; - - public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) - { - if (node is not CallHelperNode callHelper) return null; - var helperArgs = helper.GetInputArgs(callHelper); - var expression = $"{callHelper.HelperFunctionName}({helperArgs})"; - return new ExpressionStatement - { - Expression = expression, - SourceCode = expression + ";", - LineNumber = 1 - }; - } - - public IEnumerable GetOutputArms(BlueprintNode node) => []; -} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/CallNodeExportStrategy.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/CallNodeExportStrategy.cs deleted file mode 100644 index 57aba8b4..00000000 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/CallNodeExportStrategy.cs +++ /dev/null @@ -1,27 +0,0 @@ -using KitX.Core.Contract.Workflow; - -namespace KitX.Core.Workflow.Blueprint.ExportStrategies; - -public class CallNodeExportStrategy : INodeExportStrategy -{ - public BlueprintNodeType NodeType => BlueprintNodeType.Call; - public bool IsControlFlow => false; - - public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) - { - if (node is not CallNode call) return null; - var callArgs = helper.GetInputArgs(call); - var funcRef = string.IsNullOrEmpty(call.PluginName) - ? call.FunctionName - : $"{call.PluginName}.{call.FunctionName}"; - var sourceCode = $"{funcRef}({callArgs})"; - return new ExpressionStatement - { - Expression = sourceCode, - SourceCode = sourceCode + ";", - LineNumber = 1 - }; - } - - public IEnumerable GetOutputArms(BlueprintNode node) => []; -} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/GetNodeExportStrategy.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/GetNodeExportStrategy.cs deleted file mode 100644 index 1f67d3b2..00000000 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/GetNodeExportStrategy.cs +++ /dev/null @@ -1,24 +0,0 @@ -using KitX.Core.Contract.Workflow; - -namespace KitX.Core.Workflow.Blueprint.ExportStrategies; - -public class GetNodeExportStrategy : INodeExportStrategy -{ - public BlueprintNodeType NodeType => BlueprintNodeType.Get; - public bool IsControlFlow => false; - - public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) - { - if (node is not GetNode getNode) return null; - var varName = getNode.VarName; - var sourceCode = $"Get(\"{varName}\")"; - return new ExpressionStatement - { - Expression = sourceCode, - SourceCode = sourceCode + ";", - LineNumber = 1 - }; - } - - public IEnumerable GetOutputArms(BlueprintNode node) => []; -} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/LoopNodeExportStrategy.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/LoopNodeExportStrategy.cs deleted file mode 100644 index e8747959..00000000 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/LoopNodeExportStrategy.cs +++ /dev/null @@ -1,31 +0,0 @@ -using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.BlockScripting; - -namespace KitX.Core.Workflow.Blueprint.ExportStrategies; - -public class LoopNodeExportStrategy : INodeExportStrategy -{ - public BlueprintNodeType NodeType => BlueprintNodeType.Loop; - public bool IsControlFlow => true; - - public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) - { - var condition = helper.GetInputValue(node, BlockScriptWellKnown.Pins.Condition); - return new FlowControlStatement - { - ControlType = FlowControlType.Loop, - ConditionExpression = condition, - SourceCode = $"Loop({condition}, \"\", \"\");", - LineNumber = 1 - }; - } - - public IEnumerable GetOutputArms(BlueprintNode node) - { - return - [ - new OutputArmDescriptor { PinName = BlockScriptWellKnown.Pins.LoopBody, IsLoopback = true }, - new OutputArmDescriptor { PinName = BlockScriptWellKnown.Pins.LoopEnd, IsLoopback = false } - ]; - } -} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/PauseNodeExportStrategy.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/PauseNodeExportStrategy.cs deleted file mode 100644 index 12f04c06..00000000 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/PauseNodeExportStrategy.cs +++ /dev/null @@ -1,22 +0,0 @@ -using KitX.Core.Contract.Workflow; - -namespace KitX.Core.Workflow.Blueprint.ExportStrategies; - -public class PauseNodeExportStrategy : INodeExportStrategy -{ - public BlueprintNodeType NodeType => BlueprintNodeType.Pause; - public bool IsControlFlow => false; - - public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) - { - var ms = helper.GetInputValue(node, "Milliseconds"); - return new ExpressionStatement - { - Expression = $"Pause({ms});", - SourceCode = $"Pause({ms});", - LineNumber = 1 - }; - } - - public IEnumerable GetOutputArms(BlueprintNode node) => []; -} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/PrintNodeExportStrategy.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/PrintNodeExportStrategy.cs deleted file mode 100644 index cec94eff..00000000 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/PrintNodeExportStrategy.cs +++ /dev/null @@ -1,23 +0,0 @@ -using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.BlockScripting; - -namespace KitX.Core.Workflow.Blueprint.ExportStrategies; - -public class PrintNodeExportStrategy : INodeExportStrategy -{ - public BlueprintNodeType NodeType => BlueprintNodeType.Print; - public bool IsControlFlow => false; - - public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) - { - var value = helper.GetInputValue(node, BlockScriptWellKnown.Pins.Value); - return new ExpressionStatement - { - Expression = $"{BlockScriptWellKnown.Functions.Print}({value});", - SourceCode = $"{BlockScriptWellKnown.Functions.Print}({value});", - LineNumber = 1 - }; - } - - public IEnumerable GetOutputArms(BlueprintNode node) => []; -} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/SetNodeExportStrategy.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/SetNodeExportStrategy.cs deleted file mode 100644 index 4c8832cf..00000000 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExportStrategies/SetNodeExportStrategy.cs +++ /dev/null @@ -1,25 +0,0 @@ -using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.BlockScripting; - -namespace KitX.Core.Workflow.Blueprint.ExportStrategies; - -public class SetNodeExportStrategy : INodeExportStrategy -{ - public BlueprintNodeType NodeType => BlueprintNodeType.Set; - public bool IsControlFlow => false; - - public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) - { - if (node is not SetNode setNode) return null; - var value = helper.GetInputValue(node, BlockScriptWellKnown.Pins.Value); - var sourceCode = $"{BlockScriptWellKnown.Functions.Set}(\"{setNode.VarName}\", {value})"; - return new ExpressionStatement - { - Expression = sourceCode, - SourceCode = sourceCode + ";", - LineNumber = 1 - }; - } - - public IEnumerable GetOutputArms(BlueprintNode node) => []; -} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeRegistry.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeRegistry.cs index 9caf2e1b..84bf27be 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeRegistry.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeRegistry.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using KitX.Core.Contract.Workflow; +using KitX.Core.Workflow.BlockScripting; using Serilog; namespace KitX.Core.Workflow.Blueprint; @@ -14,6 +15,7 @@ public class NodeRegistry : INodeRegistry { private readonly Dictionary _typeMap; private readonly Dictionary _descriptorCache; + private readonly BuiltinFunctionRegistry? _functionRegistry; public NodeRegistry() { @@ -32,6 +34,7 @@ public NodeRegistry() [BlueprintNodeType.Print] = typeof(PrintNode), [BlueprintNodeType.Pause] = typeof(PauseNode), [BlueprintNodeType.Variable] = typeof(VariableNode), + [BlueprintNodeType.BuiltinFunction] = typeof(BuiltinFunctionNode), }; // Pre-cache descriptors from each node type @@ -45,6 +48,14 @@ public NodeRegistry() Log.Information("NodeRegistry initialized with {Count} node types", _typeMap.Count); } + /// + /// Creates NodeRegistry with a BuiltinFunctionRegistry for dynamic node creation. + /// + public NodeRegistry(BuiltinFunctionRegistry functionRegistry) : this() + { + _functionRegistry = functionRegistry; + } + /// public BlueprintNode Create(BlueprintNodeType type) { @@ -65,4 +76,38 @@ public NodeDescriptor GetDescriptor(BlueprintNodeType type) /// public IReadOnlySet RegisteredTypes => _typeMap.Keys.ToHashSet(); + + /// + public BlueprintNode CreateBuiltinFunctionNode(string functionName) + { + if (_functionRegistry == null) + throw new InvalidOperationException("BuiltinFunctionRegistry not configured. " + + "Use NodeRegistry(BuiltinFunctionRegistry) constructor."); + + var def = _functionRegistry.Get(functionName) + ?? throw new ArgumentException($"Unknown builtin function: {functionName}"); + + var node = new BuiltinFunctionNode + { + NodeType = BlueprintNodeType.BuiltinFunction, + FunctionName = functionName, + Name = def.DisplayName + }; + + // Build descriptor from the function definition + var descriptor = new NodeDescriptor( + def.NodeWidth, def.NodeHeight, + def.InputPins, def.OutputPins, + def.DisplayName + ); + node.SetDescriptor(descriptor); + + // Initialize pins from descriptor + foreach (var pd in descriptor.InputPins) + node.InputPins.Add(new BlueprintPin { Name = pd.Name, Direction = PinDirection.Input, Type = pd.Type }); + foreach (var pd in descriptor.OutputPins) + node.OutputPins.Add(new BlueprintPin { Name = pd.Name, Direction = PinDirection.Output, Type = pd.Type }); + + return node; + } } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ExprUtils.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ExprUtils.cs index eafe8d44..8ae83656 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ExprUtils.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ExprUtils.cs @@ -16,6 +16,10 @@ namespace KitX.Core.Workflow.Blueprint.Pipeline; /// public static class ExprUtils { + /// + /// All known built-in function names. Prefer for new code. + /// + [Obsolete("Use BuiltinFunctionRegistry.AllFunctionNames instead. This set only covers legacy hardcoded functions.")] public static readonly HashSet BuiltinFunctions = new() { BlockScriptWellKnown.Functions.Get, BlockScriptWellKnown.Functions.Set, @@ -24,12 +28,21 @@ public static class ExprUtils BlockScriptWellKnown.Functions.LoopBodyEnd, BlockScriptWellKnown.Functions.Break }; + /// + /// Functions that should not be extracted during argument expansion. + /// Prefer for new code. + /// + [Obsolete("Use BuiltinFunctionRegistry.NonExtractableNames instead. This set only covers legacy hardcoded functions.")] public static readonly HashSet NonExtractableFunctions = new() { BlockScriptWellKnown.Functions.Set, BlockScriptWellKnown.Functions.Print, BlockScriptWellKnown.Functions.Pause }; + /// + /// Flow control function names. Prefer for new code. + /// + [Obsolete("Use BuiltinFunctionRegistry.FlowControlNames instead. This set only covers legacy hardcoded functions.")] public static readonly HashSet FlowControlFunctions = new() { BlockScriptWellKnown.Functions.Branch, BlockScriptWellKnown.Functions.Loop, @@ -167,7 +180,9 @@ private static void CollectNested(ExpressionSyntax expr, List _helpers; private readonly HashSet _helperNames; + private readonly BuiltinFunctionRegistry? _functionRegistry; // Deferred cross-block edge definitions (resolved after all blocks processed) private readonly List<(string stmtId, string returnToBlock, string blockName, string? prevStmtId)> _loopBodyEndDefs = new(); private readonly Dictionary _blockLastStmtId = new(); - public NodeBuilder(INodeRegistry registry, List helpers) + public NodeBuilder(INodeRegistry registry, List helpers, BuiltinFunctionRegistry? functionRegistry = null) { _registry = registry; _helpers = helpers; _helperNames = new HashSet(helpers.Select(h => h.Name)); + _functionRegistry = functionRegistry; } public void Build(FormattedBlockScript script, PipelineContext context) @@ -86,7 +88,8 @@ private void ProcessBlock(FormattedBlock block, PipelineContext context) endsWithFlowCtrl = stmt.Kind is FormattedStatementKind.Branch or FormattedStatementKind.Loop or FormattedStatementKind.LoopBodyEnd - or FormattedStatementKind.Break; + or FormattedStatementKind.Break + || IsRegistryFlowControlTerminator(stmt); } if (firstNode != null) @@ -108,6 +111,30 @@ or FormattedStatementKind.LoopBodyEnd private BlueprintNode? ProcessStatement(FormattedStatement stmt, string blockName, PipelineContext context, ref BlueprintNode? prevNode, ref string? prevStmtId) { + // Registry path: handle NEW control flow terminators with FunctionName set + // (e.g. Flip). Standard functions (Branch/Loop/Break) go through FormatFlowControl + // which doesn't set FunctionName, so they use the switch cases below. + if (_functionRegistry != null && !string.IsNullOrEmpty(stmt.FunctionName)) + { + var funcDef = _functionRegistry.Get(stmt.FunctionName); + if (funcDef != null && funcDef.IsBlockTerminator) + { + // LoopBodyEnd: no node created, record for deferred resolution + if (funcDef.IsFlowControl && stmt.Kind == FormattedStatementKind.LoopBodyEnd) + { + _loopBodyEndDefs.Add((stmt.StatementId, stmt.LoopBodyEndReturnTo ?? "", blockName, prevStmtId)); + return null; + } + + var node = _registry.CreateBuiltinFunctionNode(stmt.FunctionName); + node = funcDef.ConfigureNode(node, stmt); + ChainNewNode(node, stmt, context, ref prevNode, ref prevStmtId); + funcDef.OnNodeCreated(node, stmt, context); + return node; + } + } + + // Standard function handling (FormatFlowControl sets Kind but not FunctionName) switch (stmt.Kind) { case FormattedStatementKind.Assignment: @@ -124,13 +151,6 @@ or FormattedStatementKind.LoopBodyEnd return ChainNewNode(node, stmt, context, ref prevNode, ref prevStmtId); } - case FormattedStatementKind.Get: - { - var node = (GetNode)_registry.Create(BlueprintNodeType.Get); - node.VarName = stmt.GetVarName ?? ""; - return ChainNewNode(node, stmt, context, ref prevNode, ref prevStmtId); - } - case FormattedStatementKind.Pause: return ChainNewNode(_registry.Create(BlueprintNodeType.Pause), stmt, context, ref prevNode, ref prevStmtId); @@ -145,12 +165,11 @@ or FormattedStatementKind.LoopBodyEnd { var node = ChainNewNode(_registry.Create(BlueprintNodeType.Loop), stmt, context, ref prevNode, ref prevStmtId); context.LoopDefs.Add((stmt.StatementId, stmt.TrueBlockName, stmt.FalseBlockName, blockName)); - context.LoopNodesByParent[blockName] = (LoopNode)node!; + context.LoopNodesByParent[blockName] = node!; return node; } case FormattedStatementKind.LoopBodyEnd: - // No node created. Record for deferred resolution. _loopBodyEndDefs.Add((stmt.StatementId, stmt.LoopBodyEndReturnTo ?? "", blockName, prevStmtId)); return null; @@ -162,6 +181,17 @@ or FormattedStatementKind.LoopBodyEnd } } + /// + /// Checks if a FormattedStatement corresponds to a registry-based control flow terminator. + /// Used by ProcessBlock to set endsWithFlowCtrl flag. + /// + private bool IsRegistryFlowControlTerminator(FormattedStatement stmt) + { + if (_functionRegistry == null || string.IsNullOrEmpty(stmt.FunctionName)) return false; + var def = _functionRegistry.Get(stmt.FunctionName); + return def != null && def.IsBlockTerminator; + } + // ────────────────────────────────────────────── // Call / Assignment node creation (with PubVar reuse) // ────────────────────────────────────────────── @@ -215,6 +245,12 @@ or FormattedStatementKind.LoopBodyEnd getNode.VarName = varName; mainNode = getNode; } + else if (_functionRegistry != null && _functionRegistry.Get(stmt.FunctionName!) is { } funcDef) + { + // BuiltinFunctionRegistry function → create BuiltinFunctionNode + mainNode = _registry.CreateBuiltinFunctionNode(stmt.FunctionName!); + mainNode = funcDef.ConfigureNode(mainNode, stmt); + } else { var isHelper = _helperNames.Contains(stmt.FunctionName); @@ -258,17 +294,21 @@ or FormattedStatementKind.LoopBodyEnd if (!string.IsNullOrEmpty(stmt.PubVarTarget)) { var outputPinName = stmt.FunctionName == Get ? Value : Return; - var outputPin = mainNode.OutputPins.First(p => p.Name == outputPinName); - // Get: key by PubVarTarget (each Get is unique, identified by its PubVar) - // Others: key by fingerprint (for reuse detection) - var key = stmt.FunctionName == Get ? stmt.PubVarTarget : (stmt.Fingerprint ?? stmt.PubVarTarget); - context.PubVarAssignments[key] = new PubVarAssignment + var outputPin = mainNode.OutputPins.FirstOrDefault(p => p.Name == outputPinName) + ?? mainNode.OutputPins.FirstOrDefault(p => p.Type != PinType.Execution); + if (outputPin != null) { - PubVarName = stmt.PubVarTarget, - SourceNode = mainNode, - SourcePin = outputPin, - StatementId = stmt.StatementId, - }; + // Get: key by PubVarTarget (each Get is unique, identified by its PubVar) + // Others: key by fingerprint (for reuse detection) + var key = stmt.FunctionName == Get ? stmt.PubVarTarget : (stmt.Fingerprint ?? stmt.PubVarTarget); + context.PubVarAssignments[key] = new PubVarAssignment + { + PubVarName = stmt.PubVarTarget, + SourceNode = mainNode, + SourcePin = outputPin, + StatementId = stmt.StatementId, + }; + } } return mainNode; @@ -379,6 +419,47 @@ private void ResolveCrossBlockEdges(PipelineContext context) TargetPinName = Exec }); } + + // Generic deferred edges from IBuiltinFunctionDefinition.OnNodeCreated + foreach (var deferred in context.DeferredEdges) + { + foreach (var (pinName, targetBlockName) in deferred.Arms) + { + if (string.IsNullOrEmpty(targetBlockName)) continue; + if (!context.BlockFirstNodes.TryGetValue(targetBlockName, out var firstNode)) continue; + var targetStmtId = FindStmtIdForNode(firstNode, context); + if (targetStmtId == null) continue; + + context.ExecEdges.Add(new PendingExecEdge + { + SourceStatementId = deferred.SourceStatementId, + TargetStatementId = targetStmtId, + SourcePinName = pinName, + TargetPinName = Exec, + IsSpecialRouting = true + }); + } + + // Loopback edge (LoopBodyEnd-style) + if (!string.IsNullOrEmpty(deferred.LoopbackTargetBlock)) + { + // Find the LoopNode in the target block's parent for loopback + if (context.LoopNodesByParent.TryGetValue(deferred.LoopbackTargetBlock, out var loopNode)) + { + var loopStmtId = FindStmtIdForNode(loopNode, context); + if (loopStmtId != null) + { + context.ExecEdges.Add(new PendingExecEdge + { + SourceStatementId = deferred.SourceStatementId, + TargetStatementId = loopStmtId, + SourcePinName = Exec, + TargetPinName = Exec + }); + } + } + } + } } // ────────────────────────────────────────────── diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineContext.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineContext.cs index 23b7c0d5..3eede13b 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineContext.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineContext.cs @@ -41,6 +41,9 @@ public class PipelineContext // --- Phase 4 output --- public List DataEdges { get; set; } = new(); + // --- Generic control flow deferred edges (populated by IBuiltinFunctionDefinition.OnNodeCreated) --- + public List DeferredEdges { get; set; } = new(); + // --- Shared state --- public int NextPubVarCounter { get; set; } = 0; public HashSet VisitedBlocks { get; set; } = new(); @@ -55,7 +58,7 @@ public class PipelineContext /// /// parentBlockName → LoopNode (for LoopBodyEnd resolution) /// - public Dictionary LoopNodesByParent { get; set; } = new(); + public Dictionary LoopNodesByParent { get; set; } = new(); /// /// parentBlockName → condition PubVar assignment info (for Loop condition duplication) @@ -146,3 +149,20 @@ public class PendingDataEdge public string TargetPinName { get; set; } = string.Empty; public string? PubVarName { get; set; } } + +/// +/// Generic deferred control flow edge for IBuiltinFunctionDefinition-based functions. +/// Replaces per-function BranchDefs/LoopDefs with a unified structure. +/// Populated by OnNodeCreated, resolved by NodeBuilder.ResolveCrossBlockEdges. +/// +public struct DeferredControlFlowEdge +{ + /// Source statement ID (the control flow node) + public string SourceStatementId { get; set; } + + /// Output arms: each defines a pin name and target block name + public List<(string PinName, string TargetBlockName)> Arms { get; set; } + + /// For LoopBodyEnd-style loopback: the block to return to. Null for non-loopback edges. + public string? LoopbackTargetBlock { get; set; } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ScriptFormatter.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ScriptFormatter.cs index 7d84761e..d01e0abd 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ScriptFormatter.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ScriptFormatter.cs @@ -4,6 +4,7 @@ using System.Threading; using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; +using KitX.Core.Workflow.BlockScripting; using Serilog; using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Functions; @@ -18,10 +19,12 @@ namespace KitX.Core.Workflow.Blueprint.Pipeline; public class ScriptFormatter { private readonly List _helperFunctions; + private readonly BuiltinFunctionRegistry? _functionRegistry; - public ScriptFormatter(List helperFunctions) + public ScriptFormatter(List helperFunctions, BuiltinFunctionRegistry? functionRegistry = null) { _helperFunctions = helperFunctions; + _functionRegistry = functionRegistry; } public FormattedBlockScript Format(BlockScript script, PipelineContext context) @@ -212,7 +215,9 @@ private List FormatExpressionStatement(ExpressionStatement e if (string.IsNullOrEmpty(funcName)) return result; // Skip flow control functions (handled by FlowControlStatement) +#pragma warning disable CS0618 // Type or member is obsolete if (ExprUtils.FlowControlFunctions.Contains(funcName)) return result; +#pragma warning restore CS0618 var fullFuncName = ExprUtils.GetFullMethodName(invoke); result.AddRange(FormatInvocation(invoke, funcName, blockName, context, assignedVar, fullFuncName)); @@ -252,7 +257,6 @@ private List FormatInvocation( break; case Set: kind = FormattedStatementKind.Set; - // First arg is varName (string literal) if (currentArgExprs.Count > 0) { var firstArgExpr = invoke.ArgumentList.Arguments[0].Expression; @@ -268,7 +272,6 @@ private List FormatInvocation( var firstArgExpr = invoke.ArgumentList.Arguments[0].Expression; getVarName = ExprUtils.GetStringLiteralValue(firstArgExpr) ?? currentArgExprs[0]; } - // Auto-generate PubVar if not already assigned per §4.3 (pre-expanded format may already assign PubVars) if (string.IsNullOrEmpty(assignedVar) || !context.PubVarNames.Contains(assignedVar)) { pubVarTarget = ExprUtils.GeneratePubVarName(context.NextPubVarCounter++); @@ -281,11 +284,17 @@ private List FormatInvocation( } break; default: + // Check registry for new/future functions not handled above + if (_functionRegistry != null && _functionRegistry.Get(funcName) is { } funcDef) + { + result.AddRange(funcDef.FormatInvocation(invoke, blockName, context, assignedVar)); + return result; + } + // Helper or regular function call if (!string.IsNullOrEmpty(assignedVar) && assignedVar != "_") { kind = FormattedStatementKind.Assignment; - // PubVarTarget is set only if the assigned variable is a declared PubVar pubVarTarget = context.PubVarNames.Contains(assignedVar) ? assignedVar : null; } else @@ -363,11 +372,13 @@ private List FormatInvocation( var fullFuncName = ExprUtils.GetFullMethodName(invoke); // Built-in functions (Get/Set/Print/Pause) stay inline +#pragma warning disable CS0618 if (ExprUtils.NonExtractableFunctions.Contains(funcName)) return (new(), invoke.ToString()); // Flow control functions → should not appear as arguments if (ExprUtils.FlowControlFunctions.Contains(funcName)) +#pragma warning restore CS0618 return (new(), invoke.ToString()); // Get(varName) → extract as a proper Get statement with GetVarName set. diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ReversePipeline/BlueprintAnalyzer.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ReversePipeline/BlueprintAnalyzer.cs index 442fec3b..2c31f93c 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ReversePipeline/BlueprintAnalyzer.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ReversePipeline/BlueprintAnalyzer.cs @@ -71,9 +71,10 @@ public void Analyze(ReverseConversionContext ctx) // ConstNode references don't need PubVar if (sourceNode.NodeType == BlueprintNodeType.Const) continue; - // GetNode, CallNode, CallHelperNode outputs need PubVar if consumed downstream + // GetNode, BuiltinFunctionNode(Get), CallNode, CallHelperNode outputs need PubVar if consumed downstream if (sourceNode.NodeType is BlueprintNodeType.Get or BlueprintNodeType.Call - or BlueprintNodeType.CallHelper) + or BlueprintNodeType.CallHelper + || (sourceNode is BuiltinFunctionNode bfn && bfn.FunctionName == "Get")) { var sourcePin = sourceNode.GetPinById(conn.SourcePinId); if (sourcePin == null) continue; diff --git a/KitX Standard b/KitX Standard index 27bbe787..639b79de 160000 --- a/KitX Standard +++ b/KitX Standard @@ -1 +1 @@ -Subproject commit 27bbe787977fc04a15a33aeef112161f5fcee23e +Subproject commit 639b79de393e7585b1b38d4adce844697a3e03fd From 11f86a1e0c943e68d850c7dfded02893ba7db5de Mon Sep 17 00:00:00 2001 From: StarInk Date: Fri, 10 Apr 2026 21:54:12 +0200 Subject: [PATCH 045/110] =?UTF-8?q?=F0=9F=92=BE=20Feat(BlockScript):=20?= =?UTF-8?q?=E5=A2=9E=E5=BC=BA=E5=86=85=E7=BD=AE=E5=87=BD=E6=95=B0=E5=AE=9A?= =?UTF-8?q?=E4=B9=89=EF=BC=8C=E6=94=AF=E6=8C=81=E8=AF=AD=E5=8F=A5=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E6=98=A0=E5=B0=84=E4=B8=8E=E5=AD=97=E6=AE=B5=E6=8F=90?= =?UTF-8?q?=E5=8F=96=EF=BC=8C=E5=BD=BB=E5=BA=95=E6=B8=85=E7=90=86=E6=95=A3?= =?UTF-8?q?=E5=BC=B9=E5=BC=8F=E4=BF=AE=E6=94=B9=E7=9A=84=E6=97=A7=E6=96=B9?= =?UTF-8?q?=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../KitX.Core.BluePrint.Test/Program.cs | 4 +- .../BlockScripting/BlockStatementExtractor.cs | 32 +--- .../BuiltinFunctions/BranchFunction.cs | 1 + .../BuiltinFunctions/BreakFunction.cs | 2 + .../BuiltinFunctions/FlipFunction.cs | 1 + .../BuiltinFunctions/GetFunction.cs | 36 ++++- .../BuiltinFunctions/LoopBodyEndFunction.cs | 1 + .../BuiltinFunctions/LoopFunction.cs | 1 + .../BuiltinFunctions/PauseFunction.cs | 1 + .../BuiltinFunctions/PrintFunction.cs | 1 + .../BuiltinFunctions/SetFunction.cs | 22 ++- .../IBuiltinFunctionDefinition.cs | 26 ++++ .../Workflow/Blueprint/Pipeline/ExprUtils.cs | 62 -------- .../Blueprint/Pipeline/NodeBuilder.cs | 144 +++++------------- .../Blueprint/Pipeline/PipelineAssembler.cs | 36 +---- .../Blueprint/Pipeline/ScriptFormatter.cs | 123 +++++---------- 16 files changed, 175 insertions(+), 318 deletions(-) diff --git a/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs b/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs index 310216b9..67989e07 100644 --- a/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs +++ b/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs @@ -5,6 +5,7 @@ using KitX.Core.DI; using KitX.Core.Contract.Workflow; using KitX.Core.Workflow.Blueprint; +using KitX.Core.Workflow.BlockScripting; namespace KitX.Core.BluePrint.Test; @@ -52,7 +53,8 @@ public static void Main(string[] args) } }; - var converter = new BlockScriptToBlueprintConverter(parser, nodeRegistry, layoutService); + var funcRegistry = sp.GetRequiredService(); + var converter = new BlockScriptToBlueprintConverter(parser, nodeRegistry, layoutService, funcRegistry); // ── Test A: Pre-expanded format (already in test script) ── Console.WriteLine("┌──────────────────────────────────────────┐"); diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockStatementExtractor.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockStatementExtractor.cs index 44706bb6..7e795e5d 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockStatementExtractor.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockStatementExtractor.cs @@ -212,20 +212,8 @@ private void ExtractStatements(SyntaxNode root, BlockDefinition block, BlockType { var methodName = ExprUtils.GetMethodName(invoke); - if (methodName == Branch) - { - block.Statements.Add(CreateFlowControlStatement(invoke, FlowControlType.Branch, exprStmt.GetLineNumber(), exprText)); - } - else if (methodName == Loop) - { - block.Statements.Add(CreateFlowControlStatement(invoke, FlowControlType.Loop, exprStmt.GetLineNumber(), exprText)); - } - else if (methodName == LoopBodyEnd) - { - block.Statements.Add(CreateFlowControlStatement(invoke, FlowControlType.LoopBodyEnd, exprStmt.GetLineNumber(), exprText)); - } - // Try BuiltinFunctionRegistry for new/future functions (e.g. Flip) - else if (_functionRegistry != null && _functionRegistry.Get(methodName) is { } funcDef) + // Try BuiltinFunctionRegistry for all registered functions + if (_functionRegistry != null && _functionRegistry.Get(methodName) is { } funcDef) { var stmt = funcDef.ExtractStatement(invoke, exprStmt.GetLineNumber(), exprText); if (stmt != null) @@ -256,20 +244,8 @@ private void ExtractStatements(SyntaxNode root, BlockDefinition block, BlockType var methodName = ExprUtils.GetMethodName(assignInvoke); Log.Debug("[BlockStatementExtractor] assignment.Right is InvocationExpressionSyntax, methodName = {MethodName}", methodName); - if (methodName == Branch) - { - block.Statements.Add(CreateFlowControlStatement(assignInvoke, FlowControlType.Branch, exprStmt.GetLineNumber(), exprText)); - } - else if (methodName == Loop) - { - block.Statements.Add(CreateFlowControlStatement(assignInvoke, FlowControlType.Loop, exprStmt.GetLineNumber(), exprText)); - } - else if (methodName == LoopBodyEnd) - { - block.Statements.Add(CreateFlowControlStatement(assignInvoke, FlowControlType.LoopBodyEnd, exprStmt.GetLineNumber(), exprText)); - } - // Try BuiltinFunctionRegistry for new/future functions (e.g. Flip) - else if (_functionRegistry != null && _functionRegistry.Get(methodName) is { } funcDef) + // Try BuiltinFunctionRegistry for all registered functions + if (_functionRegistry != null && _functionRegistry.Get(methodName) is { } funcDef) { var stmt = funcDef.ExtractStatement(assignInvoke, exprStmt.GetLineNumber(), exprText); if (stmt != null) diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/BranchFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/BranchFunction.cs index eafa5b20..4cdf27c9 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/BranchFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/BranchFunction.cs @@ -19,6 +19,7 @@ public class BranchFunction : IBuiltinFunctionDefinition public bool IsNonExtractable => false; public BlueprintNodeType? LegacyNodeType => BlueprintNodeType.Branch; public bool IsBlockTerminator => true; + public FormattedStatementKind StatementKind => FormattedStatementKind.Branch; public double NodeWidth => 120; public double NodeHeight => 80; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/BreakFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/BreakFunction.cs index c06484f5..b3a022a8 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/BreakFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/BreakFunction.cs @@ -14,8 +14,10 @@ public class BreakFunction : IBuiltinFunctionDefinition public string FunctionName => "Break"; public string DisplayName => "Break"; public bool IsFlowControl => true; + public bool IsBlockTerminator => true; public bool IsNonExtractable => true; public BlueprintNodeType? LegacyNodeType => BlueprintNodeType.Break; + public FormattedStatementKind StatementKind => FormattedStatementKind.Break; public double NodeWidth => 100; public double NodeHeight => 40; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/FlipFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/FlipFunction.cs index d2d3ba6d..bfcfd0cd 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/FlipFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/FlipFunction.cs @@ -20,6 +20,7 @@ public class FlipFunction : IBuiltinFunctionDefinition public bool IsFlowControl => true; public bool IsNonExtractable => true; public bool IsBlockTerminator => true; + public FormattedStatementKind StatementKind => FormattedStatementKind.Branch; public double NodeWidth => 120; public double NodeHeight => 80; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/GetFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/GetFunction.cs index ddad945e..d91c5d93 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/GetFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/GetFunction.cs @@ -14,8 +14,9 @@ public class GetFunction : IBuiltinFunctionDefinition public string FunctionName => "Get"; public string DisplayName => "Get"; public bool IsFlowControl => false; - public bool IsNonExtractable => true; + public bool IsNonExtractable => false; public BlueprintNodeType? LegacyNodeType => BlueprintNodeType.Get; + public FormattedStatementKind StatementKind => FormattedStatementKind.Assignment; public double NodeWidth => 120; public double NodeHeight => 60; @@ -74,11 +75,40 @@ public List FormatInvocation( public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) { - if (node is BuiltinFunctionNode bfn) - bfn.Properties["VarName"] = stmt.GetVarName ?? ""; + var varName = stmt.GetVarName ?? (stmt.Arguments?.Count > 0 ? stmt.Arguments[0].Trim('"') : ""); + if (node is GetNode gn) + gn.VarName = varName; + else if (node is BuiltinFunctionNode bfn) + bfn.Properties["VarName"] = varName; return node; } + public (string?, string?, string?) ExtractStatementFields( + InvocationExpressionSyntax invoke, List expandedArgs, + string? assignedVar, PipelineContext context) + { + string? getVarName = null; + if (expandedArgs.Count > 0) + { + var firstArgExpr = invoke.ArgumentList.Arguments[0].Expression; + getVarName = ExprUtils.GetStringLiteralValue(firstArgExpr) ?? expandedArgs[0]; + } + + string? pubVarTarget; + if (string.IsNullOrEmpty(assignedVar) || !context.PubVarNames.Contains(assignedVar)) + { + pubVarTarget = ExprUtils.GeneratePubVarName(context.NextPubVarCounter++); + if (!context.PubVarNames.Contains(pubVarTarget)) + context.PubVarNames.Add(pubVarTarget); + } + else + { + pubVarTarget = assignedVar; + } + + return (null, getVarName, pubVarTarget); + } + public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) { var varName = node switch diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/LoopBodyEndFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/LoopBodyEndFunction.cs index ca96c2f0..d670ba49 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/LoopBodyEndFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/LoopBodyEndFunction.cs @@ -17,6 +17,7 @@ public class LoopBodyEndFunction : IBuiltinFunctionDefinition public bool IsFlowControl => true; public bool IsNonExtractable => false; public bool IsBlockTerminator => true; + public FormattedStatementKind StatementKind => FormattedStatementKind.LoopBodyEnd; public double NodeWidth => 80; public double NodeHeight => 60; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/LoopFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/LoopFunction.cs index c7917556..796b7419 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/LoopFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/LoopFunction.cs @@ -20,6 +20,7 @@ public class LoopFunction : IBuiltinFunctionDefinition public bool IsNonExtractable => false; public BlueprintNodeType? LegacyNodeType => BlueprintNodeType.Loop; public bool IsBlockTerminator => true; + public FormattedStatementKind StatementKind => FormattedStatementKind.Loop; public double NodeWidth => 120; public double NodeHeight => 80; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PauseFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PauseFunction.cs index a954836c..7d2954a5 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PauseFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PauseFunction.cs @@ -16,6 +16,7 @@ public class PauseFunction : IBuiltinFunctionDefinition public bool IsFlowControl => false; public bool IsNonExtractable => true; public BlueprintNodeType? LegacyNodeType => BlueprintNodeType.Pause; + public FormattedStatementKind StatementKind => FormattedStatementKind.Pause; public double NodeWidth => 100; public double NodeHeight => 50; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PrintFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PrintFunction.cs index 7f306769..b6752b3c 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PrintFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PrintFunction.cs @@ -16,6 +16,7 @@ public class PrintFunction : IBuiltinFunctionDefinition public bool IsFlowControl => false; public bool IsNonExtractable => true; public BlueprintNodeType? LegacyNodeType => BlueprintNodeType.Print; + public FormattedStatementKind StatementKind => FormattedStatementKind.Print; public double NodeWidth => 100; public double NodeHeight => 50; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/SetFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/SetFunction.cs index 2bc47cb2..b07246d3 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/SetFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/SetFunction.cs @@ -16,6 +16,7 @@ public class SetFunction : IBuiltinFunctionDefinition public bool IsFlowControl => false; public bool IsNonExtractable => true; public BlueprintNodeType? LegacyNodeType => BlueprintNodeType.Set; + public FormattedStatementKind StatementKind => FormattedStatementKind.Set; public double NodeWidth => 120; public double NodeHeight => 60; @@ -62,11 +63,28 @@ public List FormatInvocation( public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) { - if (node is BuiltinFunctionNode bfn) - bfn.Properties["VarName"] = stmt.SetVarName ?? ""; + var varName = stmt.SetVarName ?? ""; + if (node is SetNode sn) + sn.VarName = varName; + else if (node is BuiltinFunctionNode bfn) + bfn.Properties["VarName"] = varName; return node; } + public (string?, string?, string?) ExtractStatementFields( + InvocationExpressionSyntax invoke, List expandedArgs, + string? assignedVar, PipelineContext context) + { + string? setVarName = null; + if (expandedArgs.Count > 0) + { + var firstArgExpr = invoke.ArgumentList.Arguments[0].Expression; + setVarName = ExprUtils.GetStringLiteralValue(firstArgExpr) ?? expandedArgs[0]; + expandedArgs.RemoveAt(0); + } + return (setVarName, null, null); + } + public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) { var varName = node switch diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/IBuiltinFunctionDefinition.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/IBuiltinFunctionDefinition.cs index 85b5f773..6dc6e47e 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/IBuiltinFunctionDefinition.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/IBuiltinFunctionDefinition.cs @@ -31,6 +31,32 @@ public interface IBuiltinFunctionDefinition /// bool IsNonExtractable { get; } + // ─── 语句类型映射 ───────────────────────────────── + + /// + /// 对应的 FormattedStatementKind。用于 ScriptFormatter 确定语句类型, + /// 以及 NodeBuilder 选择节点创建策略。 + /// + FormattedStatementKind StatementKind { get; } + + // ─── 语句字段提取(可选,默认无操作)────────────── + + /// + /// 从调用表达式中提取语句特定的字段(如 Set 的变量名、Get 的变量名和 PubVar)。 + /// ScriptFormatter.FormatInvocation 在处理已注册函数时调用此方法获取 Kind 之外的特殊字段。 + /// 默认实现不提取任何特殊字段。 + /// + /// 原始 Roslyn 调用表达式 + /// 已展开的参数列表(可被修改,如 Set 移除第一个参数) + /// 语句左侧的赋值变量名(可能为 null) + /// 管线上下文(可用于 PubVar 计数器等状态) + (string? setVarName, string? getVarName, string? pubVarTarget) ExtractStatementFields( + InvocationExpressionSyntax invoke, + List expandedArgs, + string? assignedVar, + PipelineContext context) + => (null, null, null); + // ─── 节点布局 ─────────────────────────────────── /// 蓝图节点宽度 diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ExprUtils.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ExprUtils.cs index 8ae83656..b86d4a4c 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ExprUtils.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ExprUtils.cs @@ -16,39 +16,6 @@ namespace KitX.Core.Workflow.Blueprint.Pipeline; /// public static class ExprUtils { - /// - /// All known built-in function names. Prefer for new code. - /// - [Obsolete("Use BuiltinFunctionRegistry.AllFunctionNames instead. This set only covers legacy hardcoded functions.")] - public static readonly HashSet BuiltinFunctions = new() - { - BlockScriptWellKnown.Functions.Get, BlockScriptWellKnown.Functions.Set, - BlockScriptWellKnown.Functions.Print, BlockScriptWellKnown.Functions.Pause, - BlockScriptWellKnown.Functions.Branch, BlockScriptWellKnown.Functions.Loop, - BlockScriptWellKnown.Functions.LoopBodyEnd, BlockScriptWellKnown.Functions.Break - }; - - /// - /// Functions that should not be extracted during argument expansion. - /// Prefer for new code. - /// - [Obsolete("Use BuiltinFunctionRegistry.NonExtractableNames instead. This set only covers legacy hardcoded functions.")] - public static readonly HashSet NonExtractableFunctions = new() - { - BlockScriptWellKnown.Functions.Set, BlockScriptWellKnown.Functions.Print, - BlockScriptWellKnown.Functions.Pause - }; - - /// - /// Flow control function names. Prefer for new code. - /// - [Obsolete("Use BuiltinFunctionRegistry.FlowControlNames instead. This set only covers legacy hardcoded functions.")] - public static readonly HashSet FlowControlFunctions = new() - { - BlockScriptWellKnown.Functions.Branch, BlockScriptWellKnown.Functions.Loop, - BlockScriptWellKnown.Functions.LoopBodyEnd, BlockScriptWellKnown.Functions.Break - }; - /// Parses an expression string using Roslyn. Returns null on failure. public static ExpressionSyntax? ParseExpression(string expression) { @@ -160,35 +127,6 @@ public static string GeneratePubVarName(int counter) } /// - /// Finds all nested invocation arguments in an invocation (non-builtin only). - /// Returns them in depth-first order (deepest first). - /// - public static List FindNestedInvocations(InvocationExpressionSyntax invoke) - { - var result = new List(); - foreach (var arg in invoke.ArgumentList.Arguments) - CollectNested(arg.Expression, result); - return result; - } - - private static void CollectNested(ExpressionSyntax expr, List result) - { - if (expr is InvocationExpressionSyntax invoke) - { - var funcName = GetMethodName(invoke); - // Recurse into arguments first (depth-first) - foreach (var arg in invoke.ArgumentList.Arguments) - CollectNested(arg.Expression, result); - // Only extract non-builtin, non-flow-control functions -#pragma warning disable CS0618 - if (!NonExtractableFunctions.Contains(funcName) && !FlowControlFunctions.Contains(funcName)) -#pragma warning restore CS0618 - result.Add(invoke); - } - else if (expr is ParenthesizedExpressionSyntax paren) - CollectNested(paren.Expression, result); - } - /// Computes a fingerprint string for a call expression for reuse detection. public static string ComputeFingerprint(string funcName, List args) => $"{funcName}({string.Join(",", args.Select(a => a.Trim().Replace(" ", "")))})"; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/NodeBuilder.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/NodeBuilder.cs index b8fbaa49..b37a0483 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/NodeBuilder.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/NodeBuilder.cs @@ -111,9 +111,7 @@ or FormattedStatementKind.Break private BlueprintNode? ProcessStatement(FormattedStatement stmt, string blockName, PipelineContext context, ref BlueprintNode? prevNode, ref string? prevStmtId) { - // Registry path: handle NEW control flow terminators with FunctionName set - // (e.g. Flip). Standard functions (Branch/Loop/Break) go through FormatFlowControl - // which doesn't set FunctionName, so they use the switch cases below. + // Registry path: handle all registered block terminators (Branch/Loop/LoopBodyEnd/Break/Flip) if (_functionRegistry != null && !string.IsNullOrEmpty(stmt.FunctionName)) { var funcDef = _functionRegistry.Get(stmt.FunctionName); @@ -126,7 +124,10 @@ or FormattedStatementKind.Break return null; } - var node = _registry.CreateBuiltinFunctionNode(stmt.FunctionName); + // Use LegacyNodeType when available for backward compatibility + BlueprintNode node = funcDef.LegacyNodeType is { } legacyType + ? _registry.Create(legacyType) + : _registry.CreateBuiltinFunctionNode(stmt.FunctionName); node = funcDef.ConfigureNode(node, stmt); ChainNewNode(node, stmt, context, ref prevNode, ref prevStmtId); funcDef.OnNodeCreated(node, stmt, context); @@ -134,7 +135,7 @@ or FormattedStatementKind.Break } } - // Standard function handling (FormatFlowControl sets Kind but not FunctionName) + // Non-terminator statement handling switch (stmt.Kind) { case FormattedStatementKind.Assignment: @@ -142,40 +143,32 @@ or FormattedStatementKind.Break return ProcessCallOrAssignment(stmt, context, ref prevNode, ref prevStmtId); case FormattedStatementKind.Print: - return ChainNewNode(_registry.Create(BlueprintNodeType.Print), stmt, context, ref prevNode, ref prevStmtId); - - case FormattedStatementKind.Set: - { - var node = (SetNode)_registry.Create(BlueprintNodeType.Set); - node.VarName = stmt.SetVarName ?? ""; - return ChainNewNode(node, stmt, context, ref prevNode, ref prevStmtId); - } - case FormattedStatementKind.Pause: - return ChainNewNode(_registry.Create(BlueprintNodeType.Pause), stmt, context, ref prevNode, ref prevStmtId); - - case FormattedStatementKind.Branch: - { - var node = ChainNewNode(_registry.Create(BlueprintNodeType.Branch), stmt, context, ref prevNode, ref prevStmtId); - context.BranchDefs.Add((stmt.StatementId, stmt.TrueBlockName, stmt.FalseBlockName)); - return node; - } - - case FormattedStatementKind.Loop: + case FormattedStatementKind.Set: { - var node = ChainNewNode(_registry.Create(BlueprintNodeType.Loop), stmt, context, ref prevNode, ref prevStmtId); - context.LoopDefs.Add((stmt.StatementId, stmt.TrueBlockName, stmt.FalseBlockName, blockName)); - context.LoopNodesByParent[blockName] = node!; - return node; + // Use registry to determine the legacy node type and configure it + if (_functionRegistry != null && !string.IsNullOrEmpty(stmt.FunctionName) + && _functionRegistry.Get(stmt.FunctionName) is { } funcDef + && funcDef.LegacyNodeType is { } legacyType) + { + var node = _registry.Create(legacyType); + node = funcDef.ConfigureNode(node, stmt); + return ChainNewNode(node, stmt, context, ref prevNode, ref prevStmtId); + } + // Fallback without registry (should not happen in production) + var fallbackType = stmt.Kind switch + { + FormattedStatementKind.Print => BlueprintNodeType.Print, + FormattedStatementKind.Pause => BlueprintNodeType.Pause, + FormattedStatementKind.Set => BlueprintNodeType.Set, + _ => BlueprintNodeType.Call + }; + var fallbackNode = _registry.Create(fallbackType); + if (fallbackType == BlueprintNodeType.Set && fallbackNode is SetNode sn) + sn.VarName = stmt.SetVarName ?? ""; + return ChainNewNode(fallbackNode, stmt, context, ref prevNode, ref prevStmtId); } - case FormattedStatementKind.LoopBodyEnd: - _loopBodyEndDefs.Add((stmt.StatementId, stmt.LoopBodyEndReturnTo ?? "", blockName, prevStmtId)); - return null; - - case FormattedStatementKind.Break: - return ChainNewNode(_registry.Create(BlueprintNodeType.Break), stmt, context, ref prevNode, ref prevStmtId); - default: return null; } @@ -237,18 +230,13 @@ private bool IsRegistryFlowControlTerminator(FormattedStatement stmt) // --- Create new node --- BlueprintNode mainNode; - if (stmt.FunctionName == Get) - { - // Get assignment → create GetNode - var varName = stmt.Arguments?.Count > 0 ? stmt.Arguments[0].Trim('"') : ""; - var getNode = (GetNode)_registry.Create(BlueprintNodeType.Get); - getNode.VarName = varName; - mainNode = getNode; - } - else if (_functionRegistry != null && _functionRegistry.Get(stmt.FunctionName!) is { } funcDef) + if (_functionRegistry != null && _functionRegistry.Get(stmt.FunctionName!) is { } funcDef) { - // BuiltinFunctionRegistry function → create BuiltinFunctionNode - mainNode = _registry.CreateBuiltinFunctionNode(stmt.FunctionName!); + // Registered function (including Get) → create appropriate node type + if (funcDef.LegacyNodeType is { } legacyType) + mainNode = _registry.Create(legacyType); + else + mainNode = _registry.CreateBuiltinFunctionNode(stmt.FunctionName!); mainNode = funcDef.ConfigureNode(mainNode, stmt); } else @@ -320,70 +308,6 @@ private bool IsRegistryFlowControlTerminator(FormattedStatement stmt) private void ResolveCrossBlockEdges(PipelineContext context) { - // Branch: True → trueBlock first, False → falseBlock first - foreach (var (stmtId, trueBlock, falseBlock) in context.BranchDefs) - { - if (!string.IsNullOrEmpty(trueBlock) && - context.BlockFirstNodes.TryGetValue(trueBlock, out var trueFirst)) - { - var targetStmtId = FindStmtIdForNode(trueFirst, context); - context.ExecEdges.Add(new PendingExecEdge - { - SourceStatementId = stmtId, - TargetStatementId = targetStmtId, - SourcePinName = True, - TargetPinName = Exec, - IsSpecialRouting = true - }); - } - - if (!string.IsNullOrEmpty(falseBlock) && - context.BlockFirstNodes.TryGetValue(falseBlock, out var falseFirst)) - { - var targetStmtId = FindStmtIdForNode(falseFirst, context); - context.ExecEdges.Add(new PendingExecEdge - { - SourceStatementId = stmtId, - TargetStatementId = targetStmtId, - SourcePinName = False, - TargetPinName = Exec, - IsSpecialRouting = true - }); - } - } - - // Loop: LoopBody → body first, LoopEnd → end first - foreach (var (stmtId, loopBody, loopEnd, _) in context.LoopDefs) - { - if (!string.IsNullOrEmpty(loopBody) && - context.BlockFirstNodes.TryGetValue(loopBody, out var bodyFirst)) - { - var targetStmtId = FindStmtIdForNode(bodyFirst, context); - context.ExecEdges.Add(new PendingExecEdge - { - SourceStatementId = stmtId, - TargetStatementId = targetStmtId, - SourcePinName = LoopBody, - TargetPinName = Exec, - IsSpecialRouting = true - }); - } - - if (!string.IsNullOrEmpty(loopEnd) && - context.BlockFirstNodes.TryGetValue(loopEnd, out var endFirst)) - { - var targetStmtId = FindStmtIdForNode(endFirst, context); - context.ExecEdges.Add(new PendingExecEdge - { - SourceStatementId = stmtId, - TargetStatementId = targetStmtId, - SourcePinName = LoopEnd, - TargetPinName = Exec, - IsSpecialRouting = true - }); - } - } - // LoopBodyEnd: prev node → Loop.Exec (of parent block) foreach (var (_, returnToBlock, _, prevStmtId) in _loopBodyEndDefs) { diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineAssembler.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineAssembler.cs index 60768d66..bd363334 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineAssembler.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineAssembler.cs @@ -248,37 +248,17 @@ private void BuildBlockScopes(Contract.Workflow.Blueprint bp, PipelineContext ct // Build scopesByName for ownership assignment var scopesByName = bp.BlockScopes.ToDictionary(s => s.Name, s => s); - // Assign ownership from Branch definitions - foreach (var (stmtId, trueBlock, falseBlock) in ctx.BranchDefs) + // Assign ownership from DeferredEdges (Branch/Loop/Flip etc.) + foreach (var deferred in ctx.DeferredEdges) { - if (!ctx.NodeByStatementId.TryGetValue(stmtId, out var branchNode)) continue; + if (!ctx.NodeByStatementId.TryGetValue(deferred.SourceStatementId, out var ownerNode)) continue; - if (trueBlock != null && scopesByName.TryGetValue(trueBlock, out var trueScope)) + foreach (var (pinName, targetBlockName) in deferred.Arms) { - trueScope.OwnerNodeId = branchNode.Id; - trueScope.OwnerArmName = True; - } - if (falseBlock != null && scopesByName.TryGetValue(falseBlock, out var falseScope)) - { - falseScope.OwnerNodeId = branchNode.Id; - falseScope.OwnerArmName = False; - } - } - - // Assign ownership from Loop definitions - foreach (var (stmtId, loopBody, loopEnd, _) in ctx.LoopDefs) - { - if (!ctx.NodeByStatementId.TryGetValue(stmtId, out var loopNode)) continue; - - if (loopBody != null && scopesByName.TryGetValue(loopBody, out var bodyScope)) - { - bodyScope.OwnerNodeId = loopNode.Id; - bodyScope.OwnerArmName = LoopBody; - } - if (loopEnd != null && scopesByName.TryGetValue(loopEnd, out var endScope)) - { - endScope.OwnerNodeId = loopNode.Id; - endScope.OwnerArmName = LoopEnd; + if (targetBlockName == null) continue; + if (!scopesByName.TryGetValue(targetBlockName, out var scope)) continue; + scope.OwnerNodeId = ownerNode.Id; + scope.OwnerArmName = pinName; } } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ScriptFormatter.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ScriptFormatter.cs index d01e0abd..b7b4da5b 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ScriptFormatter.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ScriptFormatter.cs @@ -122,6 +122,7 @@ private List FormatFlowControl(FlowControlStatement flowCtrl { BlockName = blockName, Kind = FormattedStatementKind.Branch, + FunctionName = Branch, ConditionPubVar = condPubVar, ConditionExpression = flowCtrl.ConditionExpression, TrueBlockName = flowCtrl.TrueBlockName, @@ -141,6 +142,7 @@ private List FormatFlowControl(FlowControlStatement flowCtrl { BlockName = blockName, Kind = FormattedStatementKind.Loop, + FunctionName = Loop, ConditionPubVar = condPubVar, ConditionExpression = flowCtrl.ConditionExpression, TrueBlockName = flowCtrl.TrueBlockName, @@ -168,6 +170,7 @@ private List FormatFlowControl(FlowControlStatement flowCtrl { BlockName = blockName, Kind = FormattedStatementKind.LoopBodyEnd, + FunctionName = LoopBodyEnd, LoopBodyEndReturnTo = flowCtrl.LoopBodyEndReturnTo, OriginalExpression = flowCtrl.SourceCode, SourceLine = flowCtrl.LineNumber @@ -179,6 +182,7 @@ private List FormatFlowControl(FlowControlStatement flowCtrl { BlockName = blockName, Kind = FormattedStatementKind.Break, + FunctionName = Break, OriginalExpression = flowCtrl.SourceCode, SourceLine = flowCtrl.LineNumber }); @@ -215,9 +219,8 @@ private List FormatExpressionStatement(ExpressionStatement e if (string.IsNullOrEmpty(funcName)) return result; // Skip flow control functions (handled by FlowControlStatement) -#pragma warning disable CS0618 // Type or member is obsolete - if (ExprUtils.FlowControlFunctions.Contains(funcName)) return result; -#pragma warning restore CS0618 + if (_functionRegistry != null && _functionRegistry.Get(funcName) is { } fcDef && fcDef.IsFlowControl) + return result; var fullFuncName = ExprUtils.GetFullMethodName(invoke); result.AddRange(FormatInvocation(invoke, funcName, blockName, context, assignedVar, fullFuncName)); @@ -249,57 +252,28 @@ private List FormatInvocation( switch (funcName) { - case Print: - kind = FormattedStatementKind.Print; - break; - case Pause: - kind = FormattedStatementKind.Pause; - break; - case Set: - kind = FormattedStatementKind.Set; - if (currentArgExprs.Count > 0) - { - var firstArgExpr = invoke.ArgumentList.Arguments[0].Expression; - var varNameLiteral = ExprUtils.GetStringLiteralValue(firstArgExpr); - setVarName = varNameLiteral ?? currentArgExprs[0]; - currentArgExprs.RemoveAt(0); - } - break; - case Get: - kind = FormattedStatementKind.Assignment; - if (currentArgExprs.Count > 0) - { - var firstArgExpr = invoke.ArgumentList.Arguments[0].Expression; - getVarName = ExprUtils.GetStringLiteralValue(firstArgExpr) ?? currentArgExprs[0]; - } - if (string.IsNullOrEmpty(assignedVar) || !context.PubVarNames.Contains(assignedVar)) - { - pubVarTarget = ExprUtils.GeneratePubVarName(context.NextPubVarCounter++); - if (!context.PubVarNames.Contains(pubVarTarget)) - context.PubVarNames.Add(pubVarTarget); - } - else - { - pubVarTarget = assignedVar; - } - break; default: - // Check registry for new/future functions not handled above + // All registered functions (Set/Get/Print/Pause/etc.) go through registry if (_functionRegistry != null && _functionRegistry.Get(funcName) is { } funcDef) { - result.AddRange(funcDef.FormatInvocation(invoke, blockName, context, assignedVar)); - return result; - } - - // Helper or regular function call - if (!string.IsNullOrEmpty(assignedVar) && assignedVar != "_") - { - kind = FormattedStatementKind.Assignment; - pubVarTarget = context.PubVarNames.Contains(assignedVar) ? assignedVar : null; + kind = funcDef.StatementKind; + var (sn, gn, pv) = funcDef.ExtractStatementFields(invoke, currentArgExprs, assignedVar, context); + setVarName = sn; + getVarName = gn; + pubVarTarget = pv; } else { - kind = FormattedStatementKind.Expression; + // Helper or regular function call + if (!string.IsNullOrEmpty(assignedVar) && assignedVar != "_") + { + kind = FormattedStatementKind.Assignment; + pubVarTarget = context.PubVarNames.Contains(assignedVar) ? assignedVar : null; + } + else + { + kind = FormattedStatementKind.Expression; + } } break; } @@ -371,45 +345,26 @@ private List FormatInvocation( var funcName = ExprUtils.GetMethodName(invoke); var fullFuncName = ExprUtils.GetFullMethodName(invoke); - // Built-in functions (Get/Set/Print/Pause) stay inline -#pragma warning disable CS0618 - if (ExprUtils.NonExtractableFunctions.Contains(funcName)) - return (new(), invoke.ToString()); - - // Flow control functions → should not appear as arguments - if (ExprUtils.FlowControlFunctions.Contains(funcName)) -#pragma warning restore CS0618 - return (new(), invoke.ToString()); - - // Get(varName) → extract as a proper Get statement with GetVarName set. - // This is critical for PubVar reuse detection in NodeBuilder: - // cloned Get statements (from Loop condition duplication) must have - // GetVarName set so the reuse check (PubVarTarget + GetVarName) can - // match them to the original Get node instead of creating duplicates. - if (funcName == Get) + // Check registry for function classification + if (_functionRegistry != null && _functionRegistry.Get(funcName) is { } inlineDef) { - var varName = invoke.ArgumentList.Arguments.Count > 0 - ? ExprUtils.GetStringLiteralValue(invoke.ArgumentList.Arguments[0].Expression) - ?? invoke.ArgumentList.Arguments[0].Expression.ToString().Trim('"') - : ""; + // Non-extractable functions (Set/Print/Pause) stay inline + if (inlineDef.IsNonExtractable) + return (new(), invoke.ToString()); - var getPubVar = ExprUtils.GeneratePubVarName(context.NextPubVarCounter++); - if (!context.PubVarNames.Contains(getPubVar)) - context.PubVarNames.Add(getPubVar); + // Flow control functions → should not appear as arguments + if (inlineDef.IsFlowControl) + return (new(), invoke.ToString()); + } - return (new List - { - new() - { - BlockName = blockName, - Kind = FormattedStatementKind.Assignment, - FunctionName = Get, - PubVarTarget = getPubVar, - GetVarName = varName, - Arguments = new List { $"\"{varName}\"" }, - OriginalExpression = $"{getPubVar} = Get(\"{varName}\")", - } - }, getPubVar); + // Check registry for functions that need extraction (e.g., Get) + // Registered functions use their FormatInvocation to create proper statements. + if (_functionRegistry != null && _functionRegistry.Get(funcName) is { } regFuncDef) + { + var formatted = regFuncDef.FormatInvocation(invoke, blockName, context, null); + var lastStmt = formatted.LastOrDefault(); + if (lastStmt?.PubVarTarget != null) + return (formatted, lastStmt.PubVarTarget); } // This is a helper/regular function call that needs extraction From ed6de0783746219980a4b7575b7a24b38dfb2d07 Mon Sep 17 00:00:00 2001 From: StarInk Date: Sat, 11 Apr 2026 00:22:42 +0200 Subject: [PATCH 046/110] =?UTF-8?q?=F0=9F=92=BE=20Feat(ConfigManager):=20?= =?UTF-8?q?=E9=87=8D=E6=9E=84=E9=85=8D=E7=BD=AE=E7=AE=A1=E7=90=86=EF=BC=8C?= =?UTF-8?q?=E6=8F=90=E5=8F=96=E5=85=AC=E5=85=B1=E5=B1=9E=E6=80=A7=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E9=80=BB=E8=BE=91=E8=87=B3=20ApplyConfig=20=E6=96=B9?= =?UTF-8?q?=E6=B3=95=20=F0=9F=92=BE=20Feat(EventService):=20=E5=BC=95?= =?UTF-8?q?=E5=85=A5=E7=B1=BB=E5=9E=8B=E5=8C=96=E4=BA=8B=E4=BB=B6=E5=A4=84?= =?UTF-8?q?=E7=90=86=E5=99=A8=EF=BC=8C=E4=BC=98=E5=8C=96=E4=BA=8B=E4=BB=B6?= =?UTF-8?q?=E8=AE=A2=E9=98=85=E4=B8=8E=E5=8F=96=E6=B6=88=E9=80=BB=E8=BE=91?= =?UTF-8?q?=20=F0=9F=92=BE=20Feat(WorkflowScriptService):=20=E5=88=9D?= =?UTF-8?q?=E5=A7=8B=E5=8C=96=20BlockScriptParser=20=E6=97=B6=E6=B3=A8?= =?UTF-8?q?=E5=86=8C=E5=86=85=E7=BD=AE=E5=87=BD=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../KitX.Core/Configuration/ConfigManager.cs | 40 ++-- .../KitX Core/KitX.Core/Event/EventService.cs | 175 ++++-------------- .../Workflow/WorkflowScriptService.cs | 4 +- KitX Clients/KitX Dashboard | 2 +- 4 files changed, 51 insertions(+), 170 deletions(-) diff --git a/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigManager.cs b/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigManager.cs index c5b8d839..bfba77f6 100644 --- a/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigManager.cs @@ -210,14 +210,7 @@ private void ReloadConfigFile(string fileName) where T : class if (config != null) { _configs[typeof(T).Name] = config; - - // Update the public properties - if (typeof(T) == typeof(AppConfig)) - AppConfig = config as IAppConfig ?? new AppConfig(); - else if (typeof(T) == typeof(PluginsConfig)) - PluginsConfig = config as IPluginsConfig ?? new PluginsConfig(); - else if (typeof(T) == typeof(SecurityConfig)) - SecurityConfig = config as ISecurityConfig ?? new SecurityConfig(); + ApplyConfig(config); Log.Information("Reloaded config file {FileName}", fileName); } @@ -365,14 +358,7 @@ public void Reload() if (config != null) { _configs[typeof(T).Name] = config; - - // Update the public properties - if (typeof(T) == typeof(AppConfig)) - AppConfig = config as IAppConfig ?? new AppConfig(); - else if (typeof(T) == typeof(PluginsConfig)) - PluginsConfig = config as IPluginsConfig ?? new PluginsConfig(); - else if (typeof(T) == typeof(SecurityConfig)) - SecurityConfig = config as ISecurityConfig ?? new SecurityConfig(); + ApplyConfig(config); } } else @@ -383,14 +369,7 @@ public void Reload() // Save default config SaveConfigFile(config, fileName); - - // Update the public properties - if (typeof(T) == typeof(AppConfig)) - AppConfig = config as IAppConfig ?? new AppConfig(); - else if (typeof(T) == typeof(PluginsConfig)) - PluginsConfig = config as IPluginsConfig ?? new PluginsConfig(); - else if (typeof(T) == typeof(SecurityConfig)) - SecurityConfig = config as ISecurityConfig ?? new SecurityConfig(); + ApplyConfig(config); } } catch (Exception ex) @@ -399,6 +378,19 @@ public void Reload() } } + /// + /// Updates the public config properties based on the config object's type + /// + private void ApplyConfig(object config) + { + if (config is IAppConfig appConfig) + AppConfig = appConfig; + else if (config is IPluginsConfig pluginsConfig) + PluginsConfig = pluginsConfig; + else if (config is ISecurityConfig securityConfig) + SecurityConfig = securityConfig; + } + /// /// Saves config file /// diff --git a/KitX Clients/KitX Core/KitX.Core/Event/EventService.cs b/KitX Clients/KitX Core/KitX.Core/Event/EventService.cs index 4570b45c..4181a739 100644 --- a/KitX Clients/KitX Core/KitX.Core/Event/EventService.cs +++ b/KitX Clients/KitX Core/KitX.Core/Event/EventService.cs @@ -1,9 +1,7 @@ using System; using System.Collections.Generic; -using System.ComponentModel; using System.Threading; using KitX.Core.Contract.Event; -using KitX.Shared.CSharp.Device; using Serilog; namespace KitX.Core.Event; @@ -27,6 +25,11 @@ public class EventService : IEventService /// private readonly object _lock = new(); + /// + /// Maps (eventName, originalHandler) to wrapper lambda for typed subscribe/unsubscribe + /// + private readonly Dictionary<(string eventName, Delegate handler), EventHandler> _typedWrapperMap = new(); + /// /// Thread-local counter to track publish depth for recursion detection /// @@ -95,12 +98,18 @@ public void Publish(string eventName, EventArgs args) try { - if (_eventHandlers.TryGetValue(eventName, out var handlers)) + // Take a snapshot of handlers under lock to avoid concurrent modification + List> snapshot; + lock (_lock) { - foreach (var handler in handlers) - { - handler.Invoke(this, args); - } + if (!_eventHandlers.TryGetValue(eventName, out var handlers)) + return; + snapshot = new List>(handlers); + } + + foreach (var handler in snapshot) + { + handler.Invoke(this, args); } } finally @@ -125,13 +134,16 @@ public void Subscribe(string eventName, EventHandler han _eventHandlers[eventName] = new List>(); } - _eventHandlers[eventName].Add((sender, args) => + EventHandler wrapper = (sender, args) => { if (args is TEventArgs typedArgs) { handler(sender, typedArgs); } - }); + }; + + _typedWrapperMap[(eventName, handler)] = wrapper; + _eventHandlers[eventName].Add(wrapper); } } @@ -144,11 +156,17 @@ public void Subscribe(string eventName, EventHandler han public void Unsubscribe(string eventName, EventHandler handler) where TEventArgs : EventArgs { - if (_eventHandlers.TryGetValue(eventName, out var handlers)) + lock (_lock) { - // Note: Exact handler removal is not supported in this implementation - // The handler wrapper makes exact matching difficult - // Consider using a different approach for production + var key = (eventName, handler); + if (_typedWrapperMap.TryGetValue(key, out var wrapper)) + { + if (_eventHandlers.TryGetValue(eventName, out var handlers)) + { + handlers.Remove(wrapper); + } + _typedWrapperMap.Remove(key); + } } } @@ -165,135 +183,4 @@ public void Publish(string eventName, TEventArgs args) Publish(eventName, (EventArgs)args); } - /// - /// Invokes a static event dynamically (legacy support) - /// - /// The event name - /// Optional parameters - [Obsolete("Use IEventService.Publish with event names instead")] - public static void Invoke(string eventName, object[]? objects = null) - { - var type = typeof(EventService); - - var eventField = type.GetField(eventName, System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic); - - if (eventField is null || !typeof(Delegate).IsAssignableFrom(eventField.FieldType)) - { - throw new ArgumentException($"No event found with the name '{eventName}'.", nameof(eventName)); - } - - var @delegate = eventField.GetValue(null) as Delegate; - - @delegate?.DynamicInvoke(objects); - } - - #region Static Events (Legacy Support) - -#pragma warning disable CS0067 // Event is never used - - /// - /// Language changed event - /// - [Obsolete("Use IEventService with EventNames.LanguageChanged instead")] - public static event Action LanguageChanged = () => { }; - - /// - /// Greeting text interval updated event - /// - [Obsolete("Use IEventService with EventNames.GreetingTextIntervalUpdated instead")] - public static event Action GreetingTextIntervalUpdated = () => { }; - - /// - /// App config changed event - /// - [Obsolete("Use IEventService with EventNames.AppConfigChanged instead")] - public static event Action AppConfigChanged = () => { }; - - /// - /// Plugins config changed event - /// - [Obsolete("Use IEventService with EventNames.PluginsConfigChanged instead")] - public static event Action PluginsConfigChanged = () => { }; - - /// - /// Mica opacity changed event - /// - [Obsolete("Use IEventService with EventNames.MicaOpacityChanged instead")] - public static event Action MicaOpacityChanged = () => { }; - - /// - /// Develop settings changed event - /// - [Obsolete("Use IEventService with EventNames.DevelopSettingsChanged instead")] - public static event Action DevelopSettingsChanged = () => { }; - - /// - /// Log config updated event - /// - [Obsolete("Use IEventService with EventNames.LogConfigUpdated instead")] - public static event Action LogConfigUpdated = () => { }; - - /// - /// Theme config changed event - /// - [Obsolete("Use IEventService with EventNames.ThemeConfigChanged instead")] - public static event Action ThemeConfigChanged = () => { }; - - /// - /// Use statistics changed event - /// - [Obsolete("Use IEventService with EventNames.UseStatisticsChanged instead")] - public static event Action UseStatisticsChanged = () => { }; - - /// - /// Devices server port changed event - /// - [Obsolete("Use IEventService with EventNames.DevicesServerPortChanged instead")] - public static event Action DevicesServerPortChanged = port => { }; - - /// - /// Plugins server port changed event - /// - [Obsolete("Use IEventService with EventNames.PluginsServerPortChanged instead")] - public static event Action PluginsServerPortChanged = port => { }; - - /// - /// Activities updated event - /// - [Obsolete("Use IEventService with EventNames.OnActivitiesUpdated instead")] - public static event Action OnActivitiesUpdated = () => { }; - - /// - /// Receive cancel exchanging device key event - /// - [Obsolete("Use IEventService with EventNames.OnReceiveCancelExchangingDeviceKey instead")] - public static event Action OnReceiveCancelExchangingDeviceKey = () => { }; - - /// - /// Exiting event - /// - [Obsolete("Use IEventService with EventNames.OnExiting instead")] - public static event Action OnExiting = () => { }; - - /// - /// Receiving device info event - /// - [Obsolete("Use IEventService with EventNames.OnReceivingDeviceInfo instead")] - public static event Action OnReceivingDeviceInfo = _ => { }; - - /// - /// Config hot reloaded event - /// - [Obsolete("Use IEventService with EventNames.OnConfigHotReloaded instead")] - public static event Action OnConfigHotReloaded = () => { }; - - /// - /// Accepting device key event - /// - [Obsolete("Use IEventService with EventNames.OnAcceptingDeviceKey instead")] - public static event Action OnAcceptingDeviceKey = _ => { }; - -#pragma warning restore CS0067 // Event is never used - - #endregion } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs index 8e247c5d..47cba739 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs @@ -15,6 +15,7 @@ using Kscript.CSharp.Parser; using KitX.Core.Contract.Workflow; using KitX.Core.Device; +using KitX.Core.Workflow.BlockScripting; using KitX.Shared.CSharp.Plugin; using Serilog; using CTask = System.Threading.Tasks.Task; @@ -689,7 +690,8 @@ public string MergeHelperFunctions(string mainCode, List helperF /// Gets the block script parser /// private BlockScripting.BlockScriptParser BlockScriptParser => - _blockScriptParser ??= new BlockScripting.BlockScriptParser(); + _blockScriptParser ??= new BlockScripting.BlockScriptParser( + BuiltinFunctionRegistry.Discover(typeof(BuiltinFunctionRegistry).Assembly)); /// /// Gets the block script executor (initialized with plugin manager) diff --git a/KitX Clients/KitX Dashboard b/KitX Clients/KitX Dashboard index 7861beb4..a1fb4d76 160000 --- a/KitX Clients/KitX Dashboard +++ b/KitX Clients/KitX Dashboard @@ -1 +1 @@ -Subproject commit 7861beb4bdce635bd423d9a6b77ac00c766e20a1 +Subproject commit a1fb4d76db5c39155621dff2a732a60a74718b84 From 606a42bfb186577d6576b0e488709a7df2ed07da Mon Sep 17 00:00:00 2001 From: StarInk Date: Sat, 11 Apr 2026 01:59:16 +0200 Subject: [PATCH 047/110] =?UTF-8?q?=F0=9F=93=9D=20Chore(Core):=20=E5=8D=87?= =?UTF-8?q?=E7=BA=A7=20Microsoft.CodeAnalysis.CSharp.Scripting=20=E8=87=B3?= =?UTF-8?q?=205.3.0=EF=BC=8C=E7=A7=BB=E9=99=A4=E6=9C=AA=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E7=9A=84Microsoft.Extensions.DependencyInjection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- KitX Clients/KitX Core/KitX.Core/KitX.Core.csproj | 5 ++--- KitX Clients/KitX Dashboard | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/KitX Clients/KitX Core/KitX.Core/KitX.Core.csproj b/KitX Clients/KitX Core/KitX.Core/KitX.Core.csproj index d880917d..47c14df6 100644 --- a/KitX Clients/KitX Core/KitX.Core/KitX.Core.csproj +++ b/KitX Clients/KitX Core/KitX.Core/KitX.Core.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -38,8 +38,7 @@ - - + diff --git a/KitX Clients/KitX Dashboard b/KitX Clients/KitX Dashboard index a1fb4d76..09578e24 160000 --- a/KitX Clients/KitX Dashboard +++ b/KitX Clients/KitX Dashboard @@ -1 +1 @@ -Subproject commit a1fb4d76db5c39155621dff2a732a60a74718b84 +Subproject commit 09578e24ba2130ffd9bbb9099707cc51feb3ad73 From 023fef89128880a1a464989043f2cb9850ba447e Mon Sep 17 00:00:00 2001 From: StarInk Date: Sat, 11 Apr 2026 03:11:16 +0200 Subject: [PATCH 048/110] =?UTF-8?q?=F0=9F=94=A7=20Fix(Core):=20=E6=B6=88?= =?UTF-8?q?=E9=99=A4=E7=BC=96=E8=AF=91=E8=AD=A6=E5=91=8A=EF=BC=8C=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E5=8F=AF=E7=A9=BA=E6=80=A7=E7=B1=BB=E5=9E=8B=E4=B8=8D?= =?UTF-8?q?=E5=8C=B9=E9=85=8D=E4=B8=8E=E6=9C=AA=E4=BD=BF=E7=94=A8=E4=BA=8B?= =?UTF-8?q?=E4=BB=B6=EF=BC=8C=E7=A7=BB=E9=99=A4=E5=A4=9A=E4=BD=99=20NuGet?= =?UTF-8?q?=20=E5=8C=85=E5=BC=95=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Device/DevicesDiscoveryServer.cs | 2 ++ .../KitX.Core/Device/DevicesServer.cs | 11 ++++++++++ .../KitX.Core/Device/PluginsServer.cs | 9 +++++++++ .../KitX.Core/Plugin/PluginsManager.cs | 20 +++++++++---------- .../BlockScriptExecutionGlobals.cs | 4 ++-- .../Blueprint/Pipeline/NodeBuilder.cs | 2 +- .../KitX.Core/Workflow/RealPluginManager.cs | 2 ++ KitX Clients/KitX Dashboard | 2 +- KitX Standard | 2 +- 9 files changed, 39 insertions(+), 15 deletions(-) diff --git a/KitX Clients/KitX Core/KitX.Core/Device/DevicesDiscoveryServer.cs b/KitX Clients/KitX Core/KitX.Core/Device/DevicesDiscoveryServer.cs index 671427f6..b5963384 100644 --- a/KitX Clients/KitX Core/KitX.Core/Device/DevicesDiscoveryServer.cs +++ b/KitX Clients/KitX Core/KitX.Core/Device/DevicesDiscoveryServer.cs @@ -85,7 +85,9 @@ public void ConfigurePort(int port) /// /// Event raised when a device goes offline /// +#pragma warning disable CS0067 public event EventHandler? DeviceOffline; +#pragma warning restore CS0067 /// /// Private constructor diff --git a/KitX Clients/KitX Core/KitX.Core/Device/DevicesServer.cs b/KitX Clients/KitX Core/KitX.Core/Device/DevicesServer.cs index 04af613b..c65b1c8f 100644 --- a/KitX Clients/KitX Core/KitX.Core/Device/DevicesServer.cs +++ b/KitX Clients/KitX Core/KitX.Core/Device/DevicesServer.cs @@ -56,7 +56,9 @@ public class DevicesServer : IDeviceServer /// /// Event raised when port changes /// +#pragma warning disable CS0067 public event EventHandler? PortChanged; +#pragma warning restore CS0067 /// /// Gets or sets the port @@ -381,6 +383,15 @@ private async System.Threading.Tasks.Task HandleExchangeKeyAsync(HttpContext con // For now, auto-accept the key exchange (in real implementation, this would show a UI) // TODO: Integrate with UI for verification code input + if (request.DeviceKey is null) + { + _isExchangingDeviceKey = false; + _exchangeDeviceKeyCode = null; + context.Response.StatusCode = 400; + await context.Response.WriteAsync("Device key is null"); + return; + } + var deviceKeyDecrypted = securityService.AesDecrypt(request.DeviceKey, _exchangeDeviceKeyCode); var deviceKeyInstance = JsonSerializer.Deserialize(deviceKeyDecrypted); diff --git a/KitX Clients/KitX Core/KitX.Core/Device/PluginsServer.cs b/KitX Clients/KitX Core/KitX.Core/Device/PluginsServer.cs index 23ecb7ce..36c0cd38 100644 --- a/KitX Clients/KitX Core/KitX.Core/Device/PluginsServer.cs +++ b/KitX Clients/KitX Core/KitX.Core/Device/PluginsServer.cs @@ -54,7 +54,9 @@ public class PluginsServer : IPluginServer /// /// Event raised when server port changes /// +#pragma warning disable CS0067 public event EventHandler? PortChanged; +#pragma warning restore CS0067 /// /// Gets the list of plugin connections @@ -457,6 +459,11 @@ public class PluginConnection : IPluginConnection, IPluginConnector /// public string? ConnectionId { get; private set; } + /// + /// IPluginConnector.ConnectionId — non-nullable explicit implementation + /// + string IPluginConnector.ConnectionId => ConnectionId!; + /// /// Gets or sets the plugin info /// @@ -485,7 +492,9 @@ public class PluginConnection : IPluginConnection, IPluginConnector /// /// Event raised when plugin reports status (IPluginConnector implementation) /// +#pragma warning disable CS0067 public event EventHandler? StatusReport; +#pragma warning restore CS0067 /// /// Constructor diff --git a/KitX Clients/KitX Core/KitX.Core/Plugin/PluginsManager.cs b/KitX Clients/KitX Core/KitX.Core/Plugin/PluginsManager.cs index 9eb8490a..d6e1578b 100644 --- a/KitX Clients/KitX Core/KitX.Core/Plugin/PluginsManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/Plugin/PluginsManager.cs @@ -267,10 +267,10 @@ public async Task ImportPluginAsync(string kxpFilePath) loaderInfo = new LoaderInfo { - LoaderName = loaderStruct.TryGetProperty("LoaderName", out var name) ? name.GetString() : "Unknown", - LoaderVersion = loaderStruct.TryGetProperty("LoaderVersion", out var version) ? version.GetString() : "1.0.0", - LoaderLanguage = loaderStruct.TryGetProperty("LoaderLanguage", out var lang) ? lang.GetString() : "Unknown", - LoaderFramework = loaderStruct.TryGetProperty("LoaderFramework", out var fw) ? fw.GetString() : "Unknown", + LoaderName = loaderStruct.TryGetProperty("LoaderName", out var name) ? name.GetString() ?? "Unknown" : "Unknown", + LoaderVersion = loaderStruct.TryGetProperty("LoaderVersion", out var version) ? version.GetString() ?? "1.0.0" : "1.0.0", + LoaderLanguage = loaderStruct.TryGetProperty("LoaderLanguage", out var lang) ? lang.GetString() ?? "Unknown" : "Unknown", + LoaderFramework = loaderStruct.TryGetProperty("LoaderFramework", out var fw) ? fw.GetString() ?? "Unknown" : "Unknown", SelfLoad = loaderStruct.TryGetProperty("SelfLoad", out var selfLoad) && selfLoad.GetBoolean(), Tags = new Dictionary() }; @@ -335,13 +335,13 @@ public async Task ImportPluginAsync(string kxpFilePath) } // Parse other fields - pluginInfo.AuthorName = pluginStruct.TryGetProperty("AuthorName", out var author) ? author.GetString() : "Unknown"; - pluginInfo.AuthorLink = pluginStruct.TryGetProperty("AuthorLink", out var authorLink) ? authorLink.GetString() : ""; - pluginInfo.PublisherName = pluginStruct.TryGetProperty("PublisherName", out var publisher) ? publisher.GetString() : "Unknown"; - pluginInfo.PublisherLink = pluginStruct.TryGetProperty("PublisherLink", out var publisherLink) ? publisherLink.GetString() : ""; - pluginInfo.IconInBase64 = pluginStruct.TryGetProperty("IconInBase64", out var icon) ? icon.GetString() : ""; + pluginInfo.AuthorName = pluginStruct.TryGetProperty("AuthorName", out var author) ? author.GetString() ?? "Unknown" : "Unknown"; + pluginInfo.AuthorLink = pluginStruct.TryGetProperty("AuthorLink", out var authorLink) ? authorLink.GetString() ?? "" : ""; + pluginInfo.PublisherName = pluginStruct.TryGetProperty("PublisherName", out var publisher) ? publisher.GetString() ?? "Unknown" : "Unknown"; + pluginInfo.PublisherLink = pluginStruct.TryGetProperty("PublisherLink", out var publisherLink) ? publisherLink.GetString() ?? "" : ""; + pluginInfo.IconInBase64 = pluginStruct.TryGetProperty("IconInBase64", out var icon) ? icon.GetString() ?? "" : ""; pluginInfo.IsMarketVersion = pluginStruct.TryGetProperty("IsMarketVersion", out var marketVer) && marketVer.GetBoolean(); - pluginInfo.RootStartupFileName = pluginStruct.TryGetProperty("RootStartupFileName", out var rootFile) ? rootFile.GetString() : ""; + pluginInfo.RootStartupFileName = pluginStruct.TryGetProperty("RootStartupFileName", out var rootFile) ? rootFile.GetString() ?? "" : ""; if (pluginStruct.TryGetProperty("PublishDate", out var publishDate)) { diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutionGlobals.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutionGlobals.cs index 6a8df58c..439dd194 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutionGlobals.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutionGlobals.cs @@ -165,7 +165,7 @@ public void Pause(int milliseconds) /// 调用插件函数。所有分发策略(类型化调用、fire-and-forget vs 同步等待) /// 由 RealPluginManager.CallAuto() 内部自动完成,调用方无需关心。 /// - public object? PluginCall(string pluginName, string methodName, params object?[] args) + public object? PluginCall(string pluginName, string methodName, params object[] args) { if (_pluginManager == null) { @@ -178,7 +178,7 @@ public void Pause(int milliseconds) { PluginName = pluginName, MethodName = methodName, - Parameters = args ?? Array.Empty() + Parameters = args ?? Array.Empty() }; try diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/NodeBuilder.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/NodeBuilder.cs index b37a0483..213961c6 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/NodeBuilder.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/NodeBuilder.cs @@ -241,7 +241,7 @@ private bool IsRegistryFlowControlTerminator(FormattedStatement stmt) } else { - var isHelper = _helperNames.Contains(stmt.FunctionName); + var isHelper = _helperNames.Contains(stmt.FunctionName ?? string.Empty); if (isHelper) { var helperNode = (CallHelperNode)_registry.Create(BlueprintNodeType.CallHelper); diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/RealPluginManager.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/RealPluginManager.cs index d1bdca60..5cf8e902 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/RealPluginManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/RealPluginManager.cs @@ -93,6 +93,8 @@ private void OnPluginMessageReceived(object? sender, PluginMessageReceivedEventA { Log.Information($"[RealPluginManager] OnPluginMessageReceived called with message: {e.Message?.Substring(0, Math.Min(200, e.Message?.Length ?? 0))}..."); + if (e.Message is null) return; + var kwc = JsonSerializer.Deserialize(e.Message, _serializerOptions); if (kwc?.Content is null) { diff --git a/KitX Clients/KitX Dashboard b/KitX Clients/KitX Dashboard index 09578e24..ca88bbf2 160000 --- a/KitX Clients/KitX Dashboard +++ b/KitX Clients/KitX Dashboard @@ -1 +1 @@ -Subproject commit 09578e24ba2130ffd9bbb9099707cc51feb3ad73 +Subproject commit ca88bbf266aae80a3d6b46d3cdb947cebcf6479a diff --git a/KitX Standard b/KitX Standard index 639b79de..4dead353 160000 --- a/KitX Standard +++ b/KitX Standard @@ -1 +1 @@ -Subproject commit 639b79de393e7585b1b38d4adce844697a3e03fd +Subproject commit 4dead353c2048e17f8a45f4b329c4c588ae71b1b From 8eab26b6b61aa607236d6355bad9ae96b1313670 Mon Sep 17 00:00:00 2001 From: StarInk Date: Sat, 11 Apr 2026 18:50:15 +0200 Subject: [PATCH 049/110] =?UTF-8?q?=F0=9F=92=BE=20Feat(Workflow):=20?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=20WorkflowStorageService=20=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E5=AD=98=E5=82=A8=E6=9C=8D=E5=8A=A1=EF=BC=8C=E6=89=A9?= =?UTF-8?q?=E5=B1=95=20WorkflowCase=20=E6=A8=A1=E5=9E=8B=E5=B1=9E=E6=80=A7?= =?UTF-8?q?=EF=BC=8C=E6=B3=A8=E5=86=8C=E6=96=B0=E6=9C=8D=E5=8A=A1=E8=87=B3?= =?UTF-8?q?=20DI=20=E5=AE=B9=E5=99=A8=EF=BC=8C=E6=96=B0=E5=A2=9E=20Workflo?= =?UTF-8?q?w=20=E7=9B=B8=E5=85=B3=E4=BA=8B=E4=BB=B6=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DI/CoreServiceCollectionExtensions.cs | 8 + .../KitX Core/KitX.Core/Event/EventNames.cs | 20 ++ .../Workflow/WorkflowScriptService.cs | 5 +- .../Workflow/WorkflowStorageService.cs | 227 ++++++++++++++++++ KitX Clients/KitX Dashboard | 2 +- KitX Standard | 2 +- 6 files changed, 261 insertions(+), 3 deletions(-) create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowStorageService.cs diff --git a/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs b/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs index 1bfaff97..caeeadf3 100644 --- a/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs +++ b/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs @@ -250,6 +250,14 @@ public static IServiceCollection AddCoreServices(this IServiceCollection service Log.Information("Registering IBlueprintService..."); services.AddSingleton(); + // Workflow Storage Service + Log.Information("Registering IWorkflowStorageService..."); + services.AddSingleton(provider => + { + var service = WorkflowStorageService.Instance; + return service; + }); + Log.Information("AddCoreServices completed."); return services; } diff --git a/KitX Clients/KitX Core/KitX.Core/Event/EventNames.cs b/KitX Clients/KitX Core/KitX.Core/Event/EventNames.cs index 45d2cfb9..e8203f27 100644 --- a/KitX Clients/KitX Core/KitX.Core/Event/EventNames.cs +++ b/KitX Clients/KitX Core/KitX.Core/Event/EventNames.cs @@ -109,4 +109,24 @@ public static class EventNames /// Plugin unregistered event /// public const string PluginUnregistered = "PluginUnregistered"; + + /// + /// Workflow created event + /// + public const string WorkflowCreated = "WorkflowCreated"; + + /// + /// Workflow deleted event + /// + public const string WorkflowDeleted = "WorkflowDeleted"; + + /// + /// Workflow renamed event + /// + public const string WorkflowRenamed = "WorkflowRenamed"; + + /// + /// Workflow data saved event + /// + public const string WorkflowDataSaved = "WorkflowDataSaved"; } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs index 47cba739..e5900dda 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs @@ -990,7 +990,10 @@ public class WorkflowCase : IWorkflowCase public string Id { get; set; } = Guid.NewGuid().ToString(); public string Name { get; set; } = "Untitled Workflow"; public string Description { get; set; } = string.Empty; - public string IconPath { get; set; } = string.Empty; + public string Author { get; set; } = string.Empty; public bool IsRunning { get; set; } public string? ScriptPath { get; set; } + public DateTime CreatedTime { get; set; } = DateTime.UtcNow; + public DateTime LastModifiedTime { get; set; } = DateTime.UtcNow; + public string TriggerType { get; set; } = "Manual"; } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowStorageService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowStorageService.cs new file mode 100644 index 00000000..05017d29 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowStorageService.cs @@ -0,0 +1,227 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using KitX.Core.Contract.Workflow; +using Serilog; + +namespace KitX.Core.Workflow; + +/// +/// Workflow storage service implementation - manages workflow file persistence +/// +public class WorkflowStorageService : IWorkflowStorageService +{ + private static readonly JsonSerializerOptions _jsonOptions = new() + { + WriteIndented = true + }; + + private static readonly Lazy _instance = new(() => new()); + public static WorkflowStorageService Instance => _instance.Value; + + private readonly string _storageDirectory; + + public WorkflowStorageService() + { + _storageDirectory = Path.Combine("./Data/", "Workflows"); + } + + /// + public string StorageDirectory => _storageDirectory; + + /// + public async Task CreateWorkflowAsync(string name, string? description = null) + { + EnsureDirectoryExists(); + + var id = Guid.NewGuid().ToString(); + var now = DateTime.UtcNow; + + var kcs = new KcsFileFormat + { + Id = id, + Name = name, + Description = description ?? string.Empty, + Author = string.Empty, + CreatedTime = now, + LastModifiedTime = now, + TriggerType = "Manual", + UseBlockMode = true, + BlockScriptSource = GetDefaultBlockScriptTemplate(), + MainProgram = string.Empty, + HelperFunctions = [], + VariableConstants = [], + BlueprintData = null, + }; + + var filePath = GetWorkflowFilePath(id); + await SaveKcsFileInternalAsync(filePath, kcs); + + Log.Information("[WorkflowStorageService] Created workflow: {Id} - {Name}", id, name); + + return new WorkflowCase + { + Id = id, + Name = name, + Description = description ?? string.Empty, + Author = string.Empty, + IsRunning = false, + ScriptPath = filePath, + CreatedTime = now, + LastModifiedTime = now, + TriggerType = "Manual", + }; + } + + /// + public async Task LoadWorkflowDataAsync(string workflowId) + { + var filePath = GetWorkflowFilePath(workflowId); + if (!File.Exists(filePath)) + { + Log.Warning("[WorkflowStorageService] Workflow file not found: {FilePath}", filePath); + return null; + } + + return await LoadKcsFileInternalAsync(filePath); + } + + /// + public async Task SaveWorkflowDataAsync(string workflowId, KcsFileFormat data) + { + EnsureDirectoryExists(); + + data.LastModifiedTime = DateTime.UtcNow; + var filePath = GetWorkflowFilePath(workflowId); + await SaveKcsFileInternalAsync(filePath, data); + + Log.Information("[WorkflowStorageService] Saved workflow: {Id}", workflowId); + } + + /// + public Task DeleteWorkflowAsync(string workflowId) + { + var filePath = GetWorkflowFilePath(workflowId); + if (File.Exists(filePath)) + { + File.Delete(filePath); + Log.Information("[WorkflowStorageService] Deleted workflow: {Id}", workflowId); + } + else + { + Log.Warning("[WorkflowStorageService] Workflow file not found for deletion: {Id}", workflowId); + } + + return Task.CompletedTask; + } + + /// + public async Task RenameWorkflowAsync(string workflowId, string newName) + { + var data = await LoadWorkflowDataAsync(workflowId); + if (data != null) + { + data.Name = newName; + await SaveWorkflowDataAsync(workflowId, data); + Log.Information("[WorkflowStorageService] Renamed workflow {Id} to: {Name}", workflowId, newName); + } + } + + /// + public async Task> DiscoverWorkflowsAsync() + { + EnsureDirectoryExists(); + + var results = new List(); + + try + { + var files = Directory.GetFiles(_storageDirectory, "*.kcs"); + foreach (var file in files) + { + try + { + var kcs = await LoadKcsFileInternalAsync(file); + if (kcs == null) continue; + + // Backward compatibility: use file name as Id if missing + var id = string.IsNullOrEmpty(kcs.Id) + ? Path.GetFileNameWithoutExtension(file) + : kcs.Id; + + // Backward compatibility: use file creation time if missing + var createdTime = kcs.CreatedTime == default + ? File.GetCreationTimeUtc(file) + : kcs.CreatedTime; + + results.Add(new WorkflowCase + { + Id = id, + Name = string.IsNullOrEmpty(kcs.Name) ? Path.GetFileNameWithoutExtension(file) : kcs.Name, + Description = kcs.Description ?? string.Empty, + Author = kcs.Author ?? string.Empty, + IsRunning = false, + ScriptPath = file, + CreatedTime = createdTime, + LastModifiedTime = kcs.LastModifiedTime == default ? File.GetLastWriteTimeUtc(file) : kcs.LastModifiedTime, + TriggerType = string.IsNullOrEmpty(kcs.TriggerType) ? "Manual" : kcs.TriggerType, + }); + } + catch (Exception ex) + { + Log.Warning(ex, "[WorkflowStorageService] Error loading workflow file: {File}", file); + } + } + } + catch (DirectoryNotFoundException) + { + Log.Information("[WorkflowStorageService] Storage directory not found, returning empty list"); + } + + Log.Information("[WorkflowStorageService] Discovered {Count} workflows", results.Count); + return results; + } + + /// + public string GetWorkflowFilePath(string workflowId) + { + return Path.Combine(_storageDirectory, $"{workflowId}.kcs"); + } + + private void EnsureDirectoryExists() + { + if (!Directory.Exists(_storageDirectory)) + Directory.CreateDirectory(_storageDirectory); + } + + private static async Task LoadKcsFileInternalAsync(string filePath) + { + try + { + var json = await File.ReadAllTextAsync(filePath); + return JsonSerializer.Deserialize(json, _jsonOptions); + } + catch (Exception ex) + { + Log.Error(ex, "[WorkflowStorageService] Error loading KCS file: {FilePath}", filePath); + return null; + } + } + + private static async Task SaveKcsFileInternalAsync(string filePath, KcsFileFormat data) + { + var json = JsonSerializer.Serialize(data, _jsonOptions); + await File.WriteAllTextAsync(filePath, json); + } + + /// + /// Returns a default BlockScript template for new workflows + /// + private static string GetDefaultBlockScriptTemplate() + { + return "#ConstBlock\n\n#PubVarBlock\n\n#MainBlock\nPrint(\"Hello, KitX Workflow!\");\n"; + } +} diff --git a/KitX Clients/KitX Dashboard b/KitX Clients/KitX Dashboard index ca88bbf2..83526d0c 160000 --- a/KitX Clients/KitX Dashboard +++ b/KitX Clients/KitX Dashboard @@ -1 +1 @@ -Subproject commit ca88bbf266aae80a3d6b46d3cdb947cebcf6479a +Subproject commit 83526d0ccbb108401ab92d1de4799ab4dd5d358b diff --git a/KitX Standard b/KitX Standard index 4dead353..88f7e0ae 160000 --- a/KitX Standard +++ b/KitX Standard @@ -1 +1 @@ -Subproject commit 4dead353c2048e17f8a45f4b329c4c588ae71b1b +Subproject commit 88f7e0ae75fe93775e595966d11bfcd53477609a From d8f9305b9410c9a0a633e31347617b1e7c063197 Mon Sep 17 00:00:00 2001 From: StarInk Date: Sun, 12 Apr 2026 01:09:17 +0200 Subject: [PATCH 050/110] =?UTF-8?q?=F0=9F=A7=A9=20Refactor(Workflow):=20?= =?UTF-8?q?=E9=87=8D=E5=A1=91Loop=E8=AF=AD=E6=B3=95=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../KitX.Core.BluePrint.Test/Program.cs | 39 +++- .../BlockScriptExecutionGlobals.cs | 6 +- .../BlockScripting/BlockScriptExecutor.cs | 12 +- .../BlockScripting/BlockScriptParser.cs | 1 + .../BlockScripting/BlockScriptWellKnown.cs | 2 +- .../BlockScripting/BlockStatementExtractor.cs | 55 +---- .../BuiltinFunctions/LoopBodyEndFunction.cs | 81 ------- .../BuiltinFunctions/LoopFunction.cs | 2 +- .../BuiltinFunctions/ToLoopCondFunction.cs | 120 +++++++++++ .../BlockScriptToBlueprintConverter.cs | 2 +- .../Workflow/Blueprint/ExecutionFlowWalker.cs | 204 +++++++++++------- .../Pipeline/FormattedBlockScript.cs | 8 +- .../Blueprint/Pipeline/NodeBuilder.cs | 33 +-- .../Blueprint/Pipeline/PipelineAssembler.cs | 104 ++------- .../Blueprint/Pipeline/PipelineContext.cs | 6 +- .../Blueprint/Pipeline/ScriptFormatter.cs | 90 +------- .../ReversePipeline/ConditionDuplicator.cs | 6 +- .../ReverseConversionContext.cs | 6 + .../Workflow/WorkflowStorageService.cs | 105 ++++++++- KitX Clients/KitX Dashboard | 2 +- KitX Standard | 2 +- 21 files changed, 443 insertions(+), 443 deletions(-) delete mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/LoopBodyEndFunction.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/ToLoopCondFunction.cs diff --git a/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs b/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs index 67989e07..4e7b8d06 100644 --- a/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs +++ b/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; using Microsoft.Extensions.DependencyInjection; using KitX.Core.DI; using KitX.Core.Contract.Workflow; @@ -262,9 +263,21 @@ private static void RunExecutionTest( // Attach helper functions with execution bodies parseResult.Script.HelperFunctions = GetExecutionHelpers(); - // Step 4: Execute + // Step 4: Execute with timeout var executor = sp.GetRequiredService(); - var result = executor.ExecuteAsync(parseResult.Script).GetAwaiter().GetResult(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + BlockScriptExecutionResult result; + + try + { + result = executor.ExecuteAsync(parseResult.Script, cancellationToken: cts.Token) + .GetAwaiter().GetResult(); + } + catch (OperationCanceledException) + { + Console.WriteLine($"[{label}] TIMEOUT: Execution exceeded 5-second limit (likely infinite loop)"); + return; + } // Step 5: Verify output // Expected: guessNum=5, targetNum=7, loopMax=3 @@ -354,6 +367,9 @@ private static string GetPreExpandedScript() => @"#ConstBlock #MainBlock Print(""开始执行工作流""); Set(""currentLoop"", 0); +NextBlock = ""LoopCond""; + +#Block LoopCond vaaa0001 = HelperFuncCompare(""BLE"", Get(""currentLoop""), loopMax); NextBlock = Loop(vaaa0001, ""LoopBody"", ""EndLogic""); @@ -376,13 +392,11 @@ private static string GetPreExpandedScript() => @"#ConstBlock #Block LessThanLogic Print(""猜小了""); -vaaa0001 = HelperFuncCompare(""BLE"", Get(""currentLoop""), loopMax); -NextBlock = LoopBodyEnd(""MainBlock""); +NextBlock = ToLoopCond(""LoopCond""); #Block GreaterThanLogic Print(""猜大了""); -vaaa0001 = HelperFuncCompare(""BLE"", Get(""currentLoop""), loopMax); -NextBlock = LoopBodyEnd(""MainBlock""); +NextBlock = ToLoopCond(""LoopCond""); #Block SuccessLogic Print(""猜对啦!""); @@ -402,6 +416,9 @@ private static string GetRawNestedScript() => @"#ConstBlock #MainBlock Print(""开始执行工作流""); Set(""currentLoop"", 0); +NextBlock = ""LoopCond""; + +#Block LoopCond NextBlock = Loop(HelperFuncCompare(""BLE"", Get(""currentLoop""), loopMax), ""LoopBody"", ""EndLogic""); #Block LoopBody @@ -422,11 +439,11 @@ private static string GetRawNestedScript() => @"#ConstBlock #Block LessThanLogic Print(""猜小了""); -NextBlock = LoopBodyEnd(""MainBlock""); +NextBlock = ToLoopCond(""LoopCond""); #Block GreaterThanLogic Print(""猜大了""); -NextBlock = LoopBodyEnd(""MainBlock""); +NextBlock = ToLoopCond(""LoopCond""); #Block SuccessLogic Print(""猜对啦!""); @@ -664,7 +681,7 @@ private static string GetBreakScript() => @"#ConstBlock #Block ContinueBlock Set(""i"", HelperFuncAdd(Get(""i""), 1)); -NextBlock = LoopBodyEnd(""MainBlock""); +NextBlock = ToLoopCond(""MainBlock""); #Block AfterLoop Print(""Loop finished with break"");"; @@ -686,11 +703,11 @@ private static string GetNestedLoopScript() => @"#ConstBlock #Block InnerBody Set(""inner"", HelperFuncAdd(Get(""inner""), 1)); -NextBlock = LoopBodyEnd(""OuterBody""); +NextBlock = ToLoopCond(""OuterBody""); #Block OuterEnd Set(""outer"", HelperFuncAdd(Get(""outer""), 1)); -NextBlock = LoopBodyEnd(""MainBlock""); +NextBlock = ToLoopCond(""MainBlock""); #Block Done Print(""Nested loops done"");"; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutionGlobals.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutionGlobals.cs index 439dd194..30b436fa 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutionGlobals.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutionGlobals.cs @@ -153,11 +153,11 @@ public void Pause(int milliseconds) } /// - /// LoopBodyEnd - marks the end of a loop body and returns to the loop condition block + /// ToLoopCond - marks the end of a loop body and returns to the loop condition block /// - public string? LoopBodyEnd(string parentBlockName) + public string? ToLoopCond(string parentBlockName) { - NextBlock = $"{parentBlockName}_Loop"; + NextBlock = parentBlockName; return NextBlock; } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutor.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutor.cs index 024aaefa..a95962b2 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutor.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutor.cs @@ -365,13 +365,13 @@ public BlockScriptValidationResult Validate(BlockScript script) result.AddError($"Block '{flow.FalseBlockName}' referenced in Loop at line {flow.LineNumber} does not exist"); } } - else if (flow.ControlType == FlowControlType.LoopBodyEnd) + else if (flow.ControlType == FlowControlType.ToLoopCond) { - // LoopBodyEnd should reference a block that contains a Loop - if (!string.IsNullOrEmpty(flow.LoopBodyEndReturnTo) && - !allBlockNames.Contains(flow.LoopBodyEndReturnTo)) + // ToLoopCond should reference a block that contains a Loop + if (!string.IsNullOrEmpty(flow.ToLoopCondReturnTo) && + !allBlockNames.Contains(flow.ToLoopCondReturnTo)) { - result.AddError($"Block '{flow.LoopBodyEndReturnTo}' referenced in LoopBodyEnd at line {flow.LineNumber} does not exist"); + result.AddError($"Block '{flow.ToLoopCondReturnTo}' referenced in ToLoopCond at line {flow.LineNumber} does not exist"); } } } @@ -834,7 +834,7 @@ private async Task ExecuteFlowControlAsync( return BlockExecutionResult.Return(null); default: - // Branch, Loop, LoopBodyEnd, etc. + // Branch, Loop, ToLoopCond, etc. // Execute the full statement (e.g., "NextBlock = Loop(...)" or "NextBlock = Branch(...)") // The built-in function will set _globals.NextBlock await EvaluateExpressionAsync(statement.SourceCode, cancellationToken); diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptParser.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptParser.cs index c3431d28..0f823667 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptParser.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptParser.cs @@ -105,6 +105,7 @@ public BlockScriptParseResult Parse(string sourceCode) script.AllBlocks.Add(blockDef); // Create LoopBlocks for blocks containing Loop statements + // NOTE: In new design, CreateLoopBlocksForBlock only sets metadata (no hidden blocks) if (blockDef.Type == BlockType.MainBlock || blockDef.Type == BlockType.NamedBlock) { _extractor.CreateLoopBlocksForBlock(blockDef, script); diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptWellKnown.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptWellKnown.cs index 315616b3..f506b014 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptWellKnown.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptWellKnown.cs @@ -38,7 +38,7 @@ public static class Functions { public const string Branch = "Branch"; public const string Loop = "Loop"; - public const string LoopBodyEnd = "LoopBodyEnd"; + public const string ToLoopCond = "ToLoopCond"; public const string Break = "Break"; public const string Print = "Print"; public const string Pause = "Pause"; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockStatementExtractor.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockStatementExtractor.cs index 7e795e5d..8ad29351 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockStatementExtractor.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockStatementExtractor.cs @@ -101,12 +101,11 @@ public BlockDefinition CreateBlockDefinition(RecognizedBlock recognized, BlockVa } /// - /// Creates LoopBlocks for blocks containing Loop statements. - /// LoopBlock is used as a "re-entry point" for loop condition re-evaluation. + /// Sets ToLoopCondReturnTo metadata for Loop statements in the block. + /// New design: Loop statements stay in their parent block, no hidden sub-blocks created. /// public void CreateLoopBlocksForBlock(BlockDefinition block, BlockScript script) { - // Find all Loop statements in this block var loopStatements = block.Statements .OfType() .Where(fs => fs.ControlType == FlowControlType.Loop) @@ -114,50 +113,12 @@ public void CreateLoopBlocksForBlock(BlockDefinition block, BlockScript script) if (loopStatements.Count == 0) return; - // Create a LoopBlock for the FIRST Loop statement - var firstLoop = loopStatements[0]; - var loopBlockName = $"{block.Name}_Loop"; - - // Create the LoopBlock - var loopBlock = new BlockDefinition - { - Type = BlockType.LoopBlock, - Name = loopBlockName, - ParentBlockName = block.Name, - LineNumber = firstLoop.LineNumber - }; - - loopBlock.NextBlockName = firstLoop.FalseBlockName; - - // Add a copy of the Loop statement to the LoopBlock - var loopBlockStatement = new FlowControlStatement - { - LineNumber = firstLoop.LineNumber, - SourceCode = firstLoop.SourceCode, - ControlType = FlowControlType.Loop, - ConditionExpression = firstLoop.ConditionExpression, - TrueBlockName = firstLoop.TrueBlockName, - FalseBlockName = firstLoop.FalseBlockName, - LoopBodyEndReturnTo = block.Name - }; - loopBlock.Statements.Add(loopBlockStatement); - - script.NamedBlocks[loopBlockName] = loopBlock; - script.AllBlocks.Add(loopBlock); - script.LoopBlocks[block.Name] = loopBlock; - - block.NextBlockName = loopBlockName; - - // Remove Loop statements from parent block since they act as terminators foreach (var loopStmt in loopStatements) - { - block.Statements.Remove(loopStmt); - } + loopStmt.ToLoopCondReturnTo = block.Name; - Log.Debug("[BlockStatementExtractor] Created LoopBlock '{LoopBlockName}' for parent '{ParentName}', " + - "parent NextBlock -> '{LoopBlockName}', Loop jumps to {TrueBlock}/{FalseBlock}", - loopBlockName, block.Name, loopBlockName, - firstLoop.TrueBlockName, firstLoop.FalseBlockName); + Log.Debug("[BlockStatementExtractor] Block '{BlockName}' contains {Count} Loop statement(s), " + + "ToLoopCondReturnTo set to '{BlockName}'", + block.Name, loopStatements.Count, block.Name); } /// @@ -343,9 +304,9 @@ private FlowControlStatement CreateFlowControlStatement(InvocationExpressionSynt statement.ConditionExpression, statement.TrueBlockName, statement.FalseBlockName); break; - case FlowControlType.LoopBodyEnd: + case FlowControlType.ToLoopCond: if (args.Count >= 1) - statement.LoopBodyEndReturnTo = GetStringLiteral(args[0].Expression); + statement.ToLoopCondReturnTo = GetStringLiteral(args[0].Expression); break; } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/LoopBodyEndFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/LoopBodyEndFunction.cs deleted file mode 100644 index d670ba49..00000000 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/LoopBodyEndFunction.cs +++ /dev/null @@ -1,81 +0,0 @@ -using System.Collections.Generic; -using Microsoft.CodeAnalysis.CSharp.Syntax; -using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.Blueprint; -using KitX.Core.Workflow.Blueprint.Pipeline; - -namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions; - -/// -/// LoopBodyEnd 内置函数 — 标记循环体的结尾并返回循环条件块。 -/// BlockScript 语法:LoopBodyEnd("parentBlockName") -/// -public class LoopBodyEndFunction : IBuiltinFunctionDefinition -{ - public string FunctionName => "LoopBodyEnd"; - public string DisplayName => "LoopBodyEnd"; - public bool IsFlowControl => true; - public bool IsNonExtractable => false; - public bool IsBlockTerminator => true; - public FormattedStatementKind StatementKind => FormattedStatementKind.LoopBodyEnd; - public double NodeWidth => 80; - public double NodeHeight => 60; - - public IReadOnlyList InputPins => [ - new("Exec", PinType.Execution, 30) - ]; - - public IReadOnlyList OutputPins => []; - - public string? GetExecutionMethodBody() => """ - public string? LoopBodyEnd(string parentBlockName) - { - NextBlock = $"{parentBlockName}_Loop"; - return NextBlock; - } - """; - - public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) - { - var args = invoke.ArgumentList.Arguments; - var stmt = new FlowControlStatement - { - LineNumber = lineNumber, - SourceCode = exprText ?? invoke.ToFullString(), - ControlType = FlowControlType.LoopBodyEnd - }; - if (args.Count >= 1) stmt.LoopBodyEndReturnTo = GetStringLiteral(args[0].Expression); - return stmt; - } - - public List FormatInvocation( - InvocationExpressionSyntax invoke, string blockName, - PipelineContext context, string? assignedVar) - { - // LoopBodyEnd is handled through FormatFlowControl, not FormatInvocation. - return []; - } - - public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) => node; - - public void OnNodeCreated(BlueprintNode node, FormattedStatement stmt, PipelineContext context) - { - // LoopBodyEnd does not create a node — handled by ProcessStatement's special case. - // No additional OnNodeCreated logic needed. - } - - public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) - { - return new FlowControlStatement - { - ControlType = FlowControlType.LoopBodyEnd, - SourceCode = "LoopBodyEnd(\"\");", - LineNumber = 1 - }; - } - - public IEnumerable GetOutputArms() => []; - - private static string GetStringLiteral(ExpressionSyntax expr) => - expr is LiteralExpressionSyntax lit ? lit.Token.ValueText : expr.ToString().Trim('"'); -} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/LoopFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/LoopFunction.cs index 796b7419..1f80e94f 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/LoopFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/LoopFunction.cs @@ -82,7 +82,7 @@ public void OnNodeCreated(BlueprintNode node, FormattedStatement stmt, PipelineC // Also record into LoopDefs for condition duplication support context.LoopDefs.Add((stmt.StatementId, stmt.TrueBlockName, stmt.FalseBlockName, stmt.BlockName)); - // Store for LoopBodyEnd loopback resolution + // Store for ToLoopCond loopback resolution context.LoopNodesByParent[stmt.BlockName] = node; } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/ToLoopCondFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/ToLoopCondFunction.cs new file mode 100644 index 00000000..f79f2d3d --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/ToLoopCondFunction.cs @@ -0,0 +1,120 @@ +using System.Collections.Generic; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using KitX.Core.Contract.Workflow; +using KitX.Core.Workflow.Blueprint; +using KitX.Core.Workflow.Blueprint.Pipeline; + +namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions; + +/// +/// ToLoopCond 内置函数 — 标记循环体的结尾并返回循环条件块。 +/// BlockScript 语法:ToLoopCond("parentBlockName") +/// +public class ToLoopCondFunction : IBuiltinFunctionDefinition +{ + public string FunctionName => "ToLoopCond"; + public string DisplayName => "ToLoopCond"; + public bool IsFlowControl => true; + public bool IsNonExtractable => false; + public bool IsBlockTerminator => true; + public FormattedStatementKind StatementKind => FormattedStatementKind.ToLoopCond; + public double NodeWidth => 80; + public double NodeHeight => 60; + + public IReadOnlyList InputPins => [ + new("Exec", PinType.Execution, 30) + ]; + + public IReadOnlyList OutputPins => [ + new("Exec", PinType.Execution, 30) + ]; + + public string? GetExecutionMethodBody() => """ + public string? ToLoopCond(string parentBlockName) + { + NextBlock = parentBlockName; + return NextBlock; + } + """; + + public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) + { + var args = invoke.ArgumentList.Arguments; + var stmt = new FlowControlStatement + { + LineNumber = lineNumber, + SourceCode = exprText ?? invoke.ToFullString(), + ControlType = FlowControlType.ToLoopCond + }; + if (args.Count >= 1) stmt.ToLoopCondReturnTo = GetStringLiteral(args[0].Expression); + return stmt; + } + + public List FormatInvocation( + InvocationExpressionSyntax invoke, string blockName, + PipelineContext context, string? assignedVar) + { + // ToLoopCond is handled through FormatFlowControl, not FormatInvocation. + return []; + } + + public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) => node; + + public void OnNodeCreated(BlueprintNode node, FormattedStatement stmt, PipelineContext context) + { + var returnToBlock = stmt.ToLoopCondReturnTo; + if (!string.IsNullOrEmpty(returnToBlock)) + { + context.DeferredEdges.Add(new DeferredControlFlowEdge + { + SourceStatementId = stmt.StatementId, + Arms = [("Exec", returnToBlock)], + }); + } + } + + public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) + { + // Trace exec output connection to find the target block name + var execOut = node.OutputPins.FirstOrDefault(p => p.Name == "Exec"); + if (execOut != null) + { + var conn = helper.Blueprint.Connections + .FirstOrDefault(c => c.SourcePinId == execOut.Id); + if (conn != null) + { + var targetNode = helper.Blueprint.GetNodeById(conn.TargetNodeId); + if (targetNode != null) + { + var targetScope = helper.Blueprint.BlockScopes + .FirstOrDefault(s => s.NodeIds.Contains(targetNode.Id)); + if (targetScope != null) + { + var returnTo = targetScope.Name; + return new FlowControlStatement + { + ControlType = FlowControlType.ToLoopCond, + ToLoopCondReturnTo = returnTo, + SourceCode = $"NextBlock = ToLoopCond(\"{returnTo}\");", + LineNumber = 1 + }; + } + } + } + } + + return new FlowControlStatement + { + ControlType = FlowControlType.ToLoopCond, + SourceCode = "ToLoopCond();", + LineNumber = 1 + }; + } + + public IEnumerable GetOutputArms() => [ + new() { PinName = "Exec", IsLoopback = false } + ]; + + private static string GetStringLiteral(ExpressionSyntax expr) => + expr is LiteralExpressionSyntax lit ? lit.Token.ValueText : expr.ToString().Trim('"'); +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlockScriptToBlueprintConverter.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlockScriptToBlueprintConverter.cs index 149c8c05..9fda311a 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlockScriptToBlueprintConverter.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlockScriptToBlueprintConverter.cs @@ -168,7 +168,7 @@ public static string DumpFormattedScript(PipelineContext context) $" | PubVarTarget={stmt.PubVarTarget} Func={stmt.FunctionName}" + $" Args=[{args}] SetVar={stmt.SetVarName} GetVar={stmt.GetVarName}" + $" CondPubVar={stmt.ConditionPubVar} True={stmt.TrueBlockName} False={stmt.FalseBlockName}" + - $" LoopBodyEndReturnTo={stmt.LoopBodyEndReturnTo}{dup}{fp}"); + $" ToLoopCondReturnTo={stmt.ToLoopCondReturnTo}{dup}{fp}"); } sb.AppendLine(); } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExecutionFlowWalker.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExecutionFlowWalker.cs index bc1c8e78..fe0a0ecd 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExecutionFlowWalker.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExecutionFlowWalker.cs @@ -56,7 +56,7 @@ public void WalkExecutionFlow(ReverseConversionContext ctx) /// /// Generates statements from stored BlockScopes (BlockScopes-based path). /// Processes each scope, generates statements for its nodes, resolves control flow, - /// and inserts LoopBodyEnd statements. + /// and inserts ToLoopCond statements. /// public void WalkFromBlockScopes(Contract.Workflow.Blueprint blueprint, ReverseConversionContext ctx) { @@ -110,7 +110,11 @@ public void WalkFromBlockScopes(Contract.Workflow.Blueprint blueprint, ReverseCo foreach (var flow in ctx.ControlFlowMap.Values) flow.RegenerateSourceCode(); - DetectAndInsertLoopBodyEnds(blueprint, ctx); + // Detect loopback edges: if an exec connection goes from a scope's last node + // to a scope containing a Loop node (and no ToLoopCond was generated), + // insert a ToLoopCond statement. This is a fallback for when ToLoopCond + // nodes are absent (e.g. Canvas round-trip losing BuiltinFunctionNode metadata). + DetectAndInsertLoopbackToLoopConds(blueprint, ctx); } // ─── Node Walking ──────────────────────────────────────────────────── @@ -123,7 +127,8 @@ private void WalkNode(BlueprintNode node, BlockDefinition currentBlock, if (loopbackTargetId != null && node.Id == loopbackTargetId) { - currentBlock.Statements.Add(CreateLoopBodyEndStatement()); + var ownerName = ctx.LoopOwnerBlockNames.TryGetValue(loopbackTargetId, out var n) ? n : null; + currentBlock.Statements.Add(CreateToLoopCondStatement(ownerName)); return; } @@ -135,10 +140,17 @@ private void WalkNode(BlueprintNode node, BlockDefinition currentBlock, { ctx.ControlFlowMap[node.Id] = flow; if (flow.ControlType == FlowControlType.Loop) + { ctx.LoopNodes[node.Id] = node; + ctx.LoopOwnerBlockNames[node.Id] = currentBlock.Name; + } } } + // If this node generated a ToLoopCond statement, don't follow exec chain + if (stmt is FlowControlStatement { ControlType: FlowControlType.ToLoopCond }) + return; + if (IsBranchNode(node) || IsLoopNode(node)) { ctx.PendingControlFlowNodes.Add(node); @@ -149,7 +161,10 @@ private void WalkNode(BlueprintNode node, BlockDefinition currentBlock, if (execOut == null) { if (loopbackTargetId != null) - currentBlock.Statements.Add(CreateLoopBodyEndStatement()); + { + var ownerName2 = ctx.LoopOwnerBlockNames.TryGetValue(loopbackTargetId, out var n2) ? n2 : null; + currentBlock.Statements.Add(CreateToLoopCondStatement(ownerName2)); + } return; } @@ -157,7 +172,10 @@ private void WalkNode(BlueprintNode node, BlockDefinition currentBlock, if (execConn == null) { if (loopbackTargetId != null) - currentBlock.Statements.Add(CreateLoopBodyEndStatement()); + { + var ownerName3 = ctx.LoopOwnerBlockNames.TryGetValue(loopbackTargetId, out var n3) ? n3 : null; + currentBlock.Statements.Add(CreateToLoopCondStatement(ownerName3)); + } return; } @@ -166,7 +184,8 @@ private void WalkNode(BlueprintNode node, BlockDefinition currentBlock, if (loopbackTargetId != null && nextNode.Id == loopbackTargetId) { - currentBlock.Statements.Add(CreateLoopBodyEndStatement()); + var ownerName4 = ctx.LoopOwnerBlockNames.TryGetValue(loopbackTargetId, out var n4) ? n4 : null; + currentBlock.Statements.Add(CreateToLoopCondStatement(ownerName4)); return; } @@ -175,13 +194,13 @@ private void WalkNode(BlueprintNode node, BlockDefinition currentBlock, // ─── Statement Generation ──────────────────────────────────────────── - private static FlowControlStatement CreateLoopBodyEndStatement(string? returnTo = null) + private static FlowControlStatement CreateToLoopCondStatement(string? returnTo = null) { var stmt = new FlowControlStatement { - ControlType = FlowControlType.LoopBodyEnd, + ControlType = FlowControlType.ToLoopCond, LineNumber = 1, - LoopBodyEndReturnTo = returnTo + ToLoopCondReturnTo = returnTo }; stmt.RegenerateSourceCode(); return stmt; @@ -438,37 +457,7 @@ private void ProcessLoopSubGraphs(BlueprintNode loopNode, ReverseConversionConte ctx.BlockNameAssignments[loopNode.Id] = (loopBodyBlockName, loopEndBlockName); } - // ─── BlockScope LoopBodyEnd Helpers ─────────────────────────────────── - - private void DetectAndInsertLoopBodyEnds(Contract.Workflow.Blueprint bp, ReverseConversionContext ctx) - { - foreach (var scope in bp.BlockScopes) - { - if (scope.NodeIds.Count == 0) continue; - if (scope.NodeIds.Any(id => ctx.NodeById.TryGetValue(id, out var n) && IsLoopNode(n))) - continue; - - var lastNodeId = scope.NodeIds[scope.NodeIds.Count - 1]; - if (!ctx.NodeById.TryGetValue(lastNodeId, out var lastNode)) continue; - - var (loopNode, _) = FollowExecChainToLoop(lastNode, ctx); - if (loopNode == null) continue; - - var loopScopeName = FindScopeContainingNode(loopNode.Id, bp); - if (loopScopeName == scope.Name) continue; - - var returnToBlock = loopScopeName; - if (returnToBlock == null) continue; - - BlockDefinition? blockDef = scope.IsMainBlock - ? ctx.Script.MainBlock - : ctx.Script.NamedBlocks.GetValueOrDefault(scope.Name); - if (blockDef == null) continue; - - GenerateConditionStatements(loopNode, blockDef, ctx); - blockDef.Statements.Add(CreateLoopBodyEndStatement(returnToBlock)); - } - } + // ─── BlockScope ToLoopCond Helpers ─────────────────────────────────── private static (BlueprintNode? loopNode, List path) FollowExecChainToLoop( BlueprintNode startNode, ReverseConversionContext ctx) @@ -499,42 +488,6 @@ private static (BlueprintNode? loopNode, List path) FollowExecCha return (null, path); } - private void GenerateConditionStatements(BlueprintNode loopNode, BlockDefinition blockDef, - ReverseConversionContext ctx) - { - var condPin = loopNode.InputPins.FirstOrDefault(p => p.Name == Condition); - if (condPin == null) return; - - var condConn = ctx.DataConnections.FirstOrDefault(c => c.TargetPinId == condPin.Id); - if (condConn == null) return; - - var generated = new HashSet(); - GenerateDataChainStatements(condConn.SourceNodeId, blockDef, ctx, generated); - } - - private void GenerateDataChainStatements(string sourceNodeId, - BlockDefinition blockDef, ReverseConversionContext ctx, HashSet generated) - { - if (generated.Contains(sourceNodeId)) return; - if (!ctx.NodeById.TryGetValue(sourceNodeId, out var sourceNode)) return; - - foreach (var inputPin in sourceNode.InputPins) - { - if (inputPin.Name == Exec) continue; - var upConn = ctx.DataConnections.FirstOrDefault(c => c.TargetPinId == inputPin.Id); - if (upConn != null) - GenerateDataChainStatements(upConn.SourceNodeId, blockDef, ctx, generated); - } - - if (sourceNode.NodeType == BlueprintNodeType.Const) return; - - generated.Add(sourceNodeId); - - var stmt = GenerateStatement(sourceNode, ctx); - if (stmt != null) - blockDef.Statements.Add(stmt); - } - private static string? FindScopeContainingNode(string nodeId, Contract.Workflow.Blueprint bp) { foreach (var scope in bp.BlockScopes) @@ -566,4 +519,103 @@ private static bool IsBreakNode(BlueprintNode node) => private static bool IsGetNode(BlueprintNode node) => IsNodeType(node, BlueprintNodeType.Get); + + // ─── Loopback Edge Detection (BlockScopes fallback) ────────────────── + + /// + /// Detects exec connections that form loopback edges (from one scope's tail + /// to a scope containing a Loop node) and inserts ToLoopCond statements + /// when none was generated. This is a fallback for when ToLoopCond nodes + /// are absent or lost during Canvas round-trips. + /// + private static void DetectAndInsertLoopbackToLoopConds( + Contract.Workflow.Blueprint blueprint, ReverseConversionContext ctx) + { + // Build: LoopNodeId → ScopeName (which scope contains each Loop node) + var loopNodeToScope = new Dictionary(); + foreach (var scope in blueprint.BlockScopes) + { + foreach (var nodeId in scope.NodeIds) + { + if (ctx.LoopNodes.ContainsKey(nodeId)) + loopNodeToScope[nodeId] = scope.Name; + } + } + + if (loopNodeToScope.Count == 0) return; + + // Build: ScopeName → set of first node IDs + var scopeFirstNodes = new Dictionary>(); + foreach (var scope in blueprint.BlockScopes) + { + if (scope.NodeIds.Count == 0) continue; + scopeFirstNodes[scope.Name] = [scope.NodeIds[0]]; + } + + // Build: ScopeName → scope's block definition + var scopeToBlock = new Dictionary(); + if (ctx.Script.MainBlock != null) + scopeToBlock[BlockScriptWellKnown.Blocks.MainBlock] = ctx.Script.MainBlock; + foreach (var kvp in ctx.Script.NamedBlocks) + scopeToBlock[kvp.Key] = kvp.Value; + + // For each scope, check if its last node has an exec connection + // leading to a node inside a Loop-containing scope. + // Also check if the scope already ends with a ToLoopCond statement. + foreach (var scope in blueprint.BlockScopes) + { + if (scope.NodeIds.Count == 0) continue; + if (!scopeToBlock.TryGetValue(scope.Name, out var block)) continue; + + // Skip scopes that already end with ToLoopCond + if (block.Statements.LastOrDefault() is FlowControlStatement { ControlType: FlowControlType.ToLoopCond }) + continue; + + // Find the last node in this scope that has an exec output connection + string? lastNodeId = null; + for (int i = scope.NodeIds.Count - 1; i >= 0; i--) + { + var nodeId = scope.NodeIds[i]; + if (!ctx.NodeById.TryGetValue(nodeId, out var node)) continue; + var execOut = node.OutputPins.FirstOrDefault(p => p.Name == Exec); + if (execOut == null) continue; + var execConn = ctx.ExecConnections.FirstOrDefault(c => c.SourcePinId == execOut.Id); + if (execConn != null) + { + lastNodeId = nodeId; + break; + } + } + + if (lastNodeId == null) continue; + if (!ctx.NodeById.TryGetValue(lastNodeId, out var lastNode)) continue; + + var lastExecOut = lastNode.OutputPins.First(p => p.Name == Exec); + var lastExecConn = ctx.ExecConnections.FirstOrDefault(c => c.SourcePinId == lastExecOut.Id); + if (lastExecConn == null) continue; + + var targetNode = blueprint.GetNodeById(lastExecConn.TargetNodeId); + if (targetNode == null) continue; + + // Check: does the target node belong to a scope that contains a Loop? + var targetScopeName = FindScopeContainingNode(targetNode.Id, blueprint); + if (targetScopeName == null) continue; + + // Check: does that scope contain a Loop node? + var targetScope = blueprint.BlockScopes.FirstOrDefault(s => s.Name == targetScopeName); + if (targetScope == null) continue; + + bool targetScopeContainsLoop = targetScope.NodeIds + .Any(nid => ctx.LoopNodes.ContainsKey(nid)); + if (!targetScopeContainsLoop) continue; + + // Skip if the target scope is the same as the source scope + if (targetScopeName == scope.Name) continue; + + // Found a loopback edge! Insert ToLoopCond pointing to the Loop's scope. + block.Statements.Add(CreateToLoopCondStatement(targetScopeName)); + Log.Debug("[ExecutionFlowWalker] Inserted fallback ToLoopCond(\"{Target}\") in scope \"{Scope}\"", + targetScopeName, scope.Name); + } + } } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/FormattedBlockScript.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/FormattedBlockScript.cs index c30557ba..846407b9 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/FormattedBlockScript.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/FormattedBlockScript.cs @@ -78,12 +78,12 @@ public class FormattedStatement public string? ConditionPubVar { get; set; } public string? TrueBlockName { get; set; } public string? FalseBlockName { get; set; } - public string? LoopBodyEndReturnTo { get; set; } + public string? ToLoopCondReturnTo { get; set; } // --- Metadata --- /// /// True if this statement was inserted by the ScriptFormatter as a Loop condition duplication - /// before a LoopBodyEnd statement. + /// before a ToLoopCond statement. /// public bool IsLoopConditionDuplication { get; set; } @@ -135,8 +135,8 @@ public enum FormattedStatementKind /// Loop(condition, loopBody, afterLoop) Loop, - /// LoopBodyEnd("parentBlock") - LoopBodyEnd, + /// ToLoopCond("parentBlock") + ToLoopCond, /// Break() Break, diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/NodeBuilder.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/NodeBuilder.cs index 213961c6..e73d0001 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/NodeBuilder.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/NodeBuilder.cs @@ -22,7 +22,7 @@ public class NodeBuilder private readonly BuiltinFunctionRegistry? _functionRegistry; // Deferred cross-block edge definitions (resolved after all blocks processed) - private readonly List<(string stmtId, string returnToBlock, string blockName, string? prevStmtId)> _loopBodyEndDefs = new(); + private readonly List<(string stmtId, string returnToBlock, string blockName, string? prevStmtId)> _loopBodyEndDefs = []; private readonly Dictionary _blockLastStmtId = new(); public NodeBuilder(INodeRegistry registry, List helpers, BuiltinFunctionRegistry? functionRegistry = null) @@ -81,13 +81,13 @@ private void ProcessBlock(FormattedBlock block, PipelineContext context) if (firstNode == null && node != null) firstNode = node; - // Record node ID in block membership (skip nulls like LoopBodyEnd) + // Record node ID in block membership (skip nulls like ToLoopCond) if (node != null) context.BlockNodeIds[block.Name].Add(node.Id); endsWithFlowCtrl = stmt.Kind is FormattedStatementKind.Branch or FormattedStatementKind.Loop - or FormattedStatementKind.LoopBodyEnd + or FormattedStatementKind.ToLoopCond or FormattedStatementKind.Break || IsRegistryFlowControlTerminator(stmt); } @@ -111,19 +111,12 @@ or FormattedStatementKind.Break private BlueprintNode? ProcessStatement(FormattedStatement stmt, string blockName, PipelineContext context, ref BlueprintNode? prevNode, ref string? prevStmtId) { - // Registry path: handle all registered block terminators (Branch/Loop/LoopBodyEnd/Break/Flip) + // Registry path: handle all registered block terminators (Branch/Loop/ToLoopCond/Break/Flip) if (_functionRegistry != null && !string.IsNullOrEmpty(stmt.FunctionName)) { var funcDef = _functionRegistry.Get(stmt.FunctionName); if (funcDef != null && funcDef.IsBlockTerminator) { - // LoopBodyEnd: no node created, record for deferred resolution - if (funcDef.IsFlowControl && stmt.Kind == FormattedStatementKind.LoopBodyEnd) - { - _loopBodyEndDefs.Add((stmt.StatementId, stmt.LoopBodyEndReturnTo ?? "", blockName, prevStmtId)); - return null; - } - // Use LegacyNodeType when available for backward compatibility BlueprintNode node = funcDef.LegacyNodeType is { } legacyType ? _registry.Create(legacyType) @@ -308,22 +301,6 @@ private bool IsRegistryFlowControlTerminator(FormattedStatement stmt) private void ResolveCrossBlockEdges(PipelineContext context) { - // LoopBodyEnd: prev node → Loop.Exec (of parent block) - foreach (var (_, returnToBlock, _, prevStmtId) in _loopBodyEndDefs) - { - if (!context.LoopNodesByParent.TryGetValue(returnToBlock, out var loopNode)) continue; - var loopStmtId = FindStmtIdForNode(loopNode, context); - if (prevStmtId == null || loopStmtId == null) continue; - - context.ExecEdges.Add(new PendingExecEdge - { - SourceStatementId = prevStmtId, - TargetStatementId = loopStmtId, - SourcePinName = Exec, - TargetPinName = Exec - }); - } - // Sequential fall-through for blocks without flow control endings foreach (var (blockName, nextBlockName) in context.BlockNextBlock) { @@ -364,7 +341,7 @@ private void ResolveCrossBlockEdges(PipelineContext context) }); } - // Loopback edge (LoopBodyEnd-style) + // Loopback edge (ToLoopCond-style) if (!string.IsNullOrEmpty(deferred.LoopbackTargetBlock)) { // Find the LoopNode in the target block's parent for loopback diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineAssembler.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineAssembler.cs index bd363334..acef627d 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineAssembler.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineAssembler.cs @@ -178,77 +178,28 @@ private void BuildBlockScopes(Contract.Workflow.Blueprint bp, PipelineContext ct { var mainBlockName = ctx.FormattedScript.MainBlockName; - // Identify which blocks are Branch/Loop targets (have owners) - var ownedBlockNames = new HashSet(); - foreach (var (_, trueBlock, falseBlock) in ctx.BranchDefs) - { - if (trueBlock != null) ownedBlockNames.Add(trueBlock); - if (falseBlock != null) ownedBlockNames.Add(falseBlock); - } - foreach (var (_, loopBody, loopEnd, _) in ctx.LoopDefs) - { - if (loopBody != null) ownedBlockNames.Add(loopBody); - if (loopEnd != null) ownedBlockNames.Add(loopEnd); - } - - // Build temporary scopes for all formatted blocks - var tempScopes = new Dictionary nodeIds, string? nextBlock)>(); + // Build a BlockScope for each formatted block — no merging. + // Each #Block in BlockScript is an explicit semantic boundary chosen by the user; + // preserving them ensures BP↔BS round-trip fidelity and avoids semantic errors + // (e.g. ToLoopCond returning to a block that re-initializes variables). + var assignedNodeIds = new HashSet(); foreach (var block in ctx.FormattedScript.Blocks) { - var scope = new BlueprintBlockScope - { - Name = block.Name, - IsMainBlock = block.Name == mainBlockName, - NextBlockName = block.NextBlockName, - }; - var nodeIds = new List(); if (ctx.BlockNodeIds.TryGetValue(block.Name, out var ids)) - nodeIds = new List(ids); - - tempScopes[block.Name] = (scope, nodeIds, block.NextBlockName); - } - - // Merge sequential (non-owned) blocks into their parent chain - // A block is "sequential" if it's not the main block and not owned by a Branch/Loop - var merged = new HashSet(); - foreach (var kvp in tempScopes.ToList()) - { - var (scope, nodeIds, nextBlock) = kvp.Value; - if (scope.IsMainBlock || ownedBlockNames.Contains(scope.Name)) - continue; + nodeIds = ids.Where(id => assignedNodeIds.Add(id)).ToList(); - // Find the root ancestor: follow NextBlockName chain upward to find the named/owned block - var ancestorName = FindAncestor(tempScopes, kvp.Key, ownedBlockNames, mainBlockName); - if (ancestorName != null && ancestorName != kvp.Key) + bp.BlockScopes.Add(new BlueprintBlockScope { - // Merge this scope's nodes into the ancestor - var (ancestorScope, ancestorNodeIds, _) = tempScopes[ancestorName]; - ancestorNodeIds.AddRange(nodeIds); - merged.Add(kvp.Key); - } - } - - // Remove merged scopes and add remaining to Blueprint. - // Deduplicate: each node belongs to its FIRST occurrence block only. - // This prevents shared data nodes (e.g., loop condition Compare nodes - // duplicated into LoopBodyEnd blocks) from appearing in multiple scopes. - var assignedNodeIds = new HashSet(); - foreach (var kvp in tempScopes) - { - if (merged.Contains(kvp.Key)) continue; - - var (scope, nodeIds, _) = kvp.Value; - // Filter out nodes already assigned to an earlier block - var uniqueNodeIds = nodeIds.Where(id => assignedNodeIds.Add(id)).ToList(); - scope.NodeIds = uniqueNodeIds; - bp.BlockScopes.Add(scope); + Name = block.Name, + IsMainBlock = block.Name == mainBlockName, + NextBlockName = block.NextBlockName, + NodeIds = nodeIds, + }); } - // Build scopesByName for ownership assignment + // Assign ownership from DeferredEdges (Branch/Loop/ToLoopCond etc.) var scopesByName = bp.BlockScopes.ToDictionary(s => s.Name, s => s); - - // Assign ownership from DeferredEdges (Branch/Loop/Flip etc.) foreach (var deferred in ctx.DeferredEdges) { if (!ctx.NodeByStatementId.TryGetValue(deferred.SourceStatementId, out var ownerNode)) continue; @@ -262,33 +213,8 @@ private void BuildBlockScopes(Contract.Workflow.Blueprint bp, PipelineContext ct } } - Log.Debug("[PipelineAssembler] Built {ScopeCount} block scopes (merged {MergedCount} intermediate)", - bp.BlockScopes.Count, merged.Count); + Log.Debug("[PipelineAssembler] Built {ScopeCount} block scopes", + bp.BlockScopes.Count); } - /// - /// Walks the NextBlockName chain backward from a block to find its named/owned ancestor. - /// Returns null if the block is orphaned. - /// - private string? FindAncestor( - Dictionary nodeIds, string? nextBlock)> tempScopes, - string blockName, - HashSet ownedBlockNames, - string mainBlockName) - { - // Follow the chain: find which block has NextBlockName pointing to our block - foreach (var kvp in tempScopes) - { - var (_, _, nextBlock) = kvp.Value; - if (nextBlock == blockName) - { - // The parent is this block - if (kvp.Key == mainBlockName || ownedBlockNames.Contains(kvp.Key)) - return kvp.Key; - // Recurse to find the grandparent - return FindAncestor(tempScopes, kvp.Key, ownedBlockNames, mainBlockName); - } - } - return null; - } } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineContext.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineContext.cs index 3eede13b..a3d1e12a 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineContext.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineContext.cs @@ -56,7 +56,7 @@ public class PipelineContext public Dictionary PubVarAssignments { get; set; } = new(); /// - /// parentBlockName → LoopNode (for LoopBodyEnd resolution) + /// parentBlockName → LoopNode (for ToLoopCond resolution) /// public Dictionary LoopNodesByParent { get; set; } = new(); @@ -98,7 +98,7 @@ public class SubAssignment } /// -/// Information about a Loop's condition for duplication before LoopBodyEnd. +/// Information about a Loop's condition for duplication before ToLoopCond. /// public class ConditionInfo { @@ -163,6 +163,6 @@ public struct DeferredControlFlowEdge /// Output arms: each defines a pin name and target block name public List<(string PinName, string TargetBlockName)> Arms { get; set; } - /// For LoopBodyEnd-style loopback: the block to return to. Null for non-loopback edges. + /// For ToLoopCond-style loopback: the block to return to. Null for non-loopback edges. public string? LoopbackTargetBlock { get; set; } } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ScriptFormatter.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ScriptFormatter.cs index b7b4da5b..b14205e4 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ScriptFormatter.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ScriptFormatter.cs @@ -14,7 +14,7 @@ namespace KitX.Core.Workflow.Blueprint.Pipeline; /// /// Phase 2: Takes a parsed BlockScript AST and produces a FormattedBlockScript /// where all nested function calls have been expanded into PubVar assignments. -/// Also duplicates Loop condition evaluations before LoopBodyEnd statements. +/// Also duplicates Loop condition evaluations before ToLoopCond statements. /// public class ScriptFormatter { @@ -57,18 +57,6 @@ public FormattedBlockScript Format(BlockScript script, PipelineContext context) } } - // Format LoopBlocks - foreach (var kvp in script.LoopBlocks) - { - if (!result.Blocks.Any(b => b.Name == kvp.Value.Name)) - { - result.Blocks.Add(FormatBlock(kvp.Value, context)); - } - } - - // Insert Loop condition duplications before LoopBodyEnd - InsertLoopConditionDuplications(result, context); - Log.Debug("[ScriptFormatter] Done: {BlockCount} blocks, {StmtCount} statements", result.Blocks.Count, result.Blocks.Sum(b => b.Statements.Count)); @@ -152,7 +140,7 @@ private List FormatFlowControl(FlowControlStatement flowCtrl }; result.Add(loopStmt); - // Store condition for duplication before LoopBodyEnd + // Store condition for duplication before ToLoopCond if (!string.IsNullOrEmpty(condPubVar)) { context.LoopConditions[blockName] = new ConditionInfo @@ -165,13 +153,13 @@ private List FormatFlowControl(FlowControlStatement flowCtrl } break; - case FlowControlType.LoopBodyEnd: + case FlowControlType.ToLoopCond: result.Add(new FormattedStatement { BlockName = blockName, - Kind = FormattedStatementKind.LoopBodyEnd, - FunctionName = LoopBodyEnd, - LoopBodyEndReturnTo = flowCtrl.LoopBodyEndReturnTo, + Kind = FormattedStatementKind.ToLoopCond, + FunctionName = ToLoopCond, + ToLoopCondReturnTo = flowCtrl.ToLoopCondReturnTo, OriginalExpression = flowCtrl.SourceCode, SourceLine = flowCtrl.LineNumber }); @@ -469,70 +457,4 @@ private List FormatInvocation( return (result, null); } - - // ────────────────────────────────────────────── - // Loop condition duplication before LoopBodyEnd - // ────────────────────────────────────────────── - - private void InsertLoopConditionDuplications(FormattedBlockScript script, PipelineContext context) - { - foreach (var block in script.Blocks) - { - var insertions = new List<(int index, List stmts)>(); - - for (int i = 0; i < block.Statements.Count; i++) - { - var stmt = block.Statements[i]; - if (stmt.Kind == FormattedStatementKind.LoopBodyEnd - && !string.IsNullOrEmpty(stmt.LoopBodyEndReturnTo)) - { - // Try both the original block name and the Loop sub-block name - // (parser moves Loop stmts into {ParentBlock}_Loop sub-blocks) - var lookupKeys = new[] { stmt.LoopBodyEndReturnTo, $"{stmt.LoopBodyEndReturnTo}_Loop" }; - ConditionInfo? condInfo = null; - foreach (var key in lookupKeys) - { - if (context.LoopConditions.TryGetValue(key, out var ci) && ci.ExpansionStatements.Count > 0) - { - condInfo = ci; - break; - } - } - - if (condInfo != null) - { - var dupStmts = condInfo.ExpansionStatements.Select(CloneStatement).ToList(); - dupStmts.ForEach(s => s.IsLoopConditionDuplication = true); - insertions.Add((i, dupStmts)); - } - } - } - - // Apply in reverse order to preserve indices - foreach (var (index, stmts) in insertions.OrderByDescending(x => x.index)) - block.Statements.InsertRange(index, stmts); - } - } - - private FormattedStatement CloneStatement(FormattedStatement source) => new() - { - StatementId = Guid.NewGuid().ToString(), - BlockName = source.BlockName, - OriginalExpression = source.OriginalExpression, - Kind = source.Kind, - PubVarTarget = source.PubVarTarget, - FunctionName = source.FunctionName, - FullFunctionName = source.FullFunctionName, - Arguments = new List(source.Arguments), - ConditionExpression = source.ConditionExpression, - ConditionPubVar = source.ConditionPubVar, - TrueBlockName = source.TrueBlockName, - FalseBlockName = source.FalseBlockName, - LoopBodyEndReturnTo = source.LoopBodyEndReturnTo, - Fingerprint = source.Fingerprint, - SetVarName = source.SetVarName, - GetVarName = source.GetVarName, - IsLoopConditionDuplication = source.IsLoopConditionDuplication, - SourceLine = source.SourceLine - }; } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ReversePipeline/ConditionDuplicator.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ReversePipeline/ConditionDuplicator.cs index 1a0dd5f5..9712d2f4 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ReversePipeline/ConditionDuplicator.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ReversePipeline/ConditionDuplicator.cs @@ -8,7 +8,7 @@ namespace KitX.Core.Workflow.Blueprint.ReversePipeline; /// /// Phase 3 of reverse conversion: duplicates Loop condition evaluation statements -/// before each LoopBodyEnd in loop body blocks. +/// before each ToLoopCond in loop body blocks. /// internal class ConditionDuplicator { @@ -30,14 +30,14 @@ public void Duplicate(ReverseConversionContext ctx) var condStmts = FindConditionStatements(loopNode, ctx); if (condStmts.Count == 0) continue; - // Insert duplicates before each LoopBodyEnd in the loop body block + // Insert duplicates before each ToLoopCond in the loop body block var insertions = new List<(int index, List stmts)>(); for (int i = 0; i < loopBodyBlock.Statements.Count; i++) { var stmt = loopBodyBlock.Statements[i]; if (stmt is FlowControlStatement flowStmt - && flowStmt.ControlType == FlowControlType.LoopBodyEnd) + && flowStmt.ControlType == FlowControlType.ToLoopCond) { var dupStmts = condStmts.Select(CloneStatement).ToList(); insertions.Add((i, dupStmts.Cast().ToList())); diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ReversePipeline/ReverseConversionContext.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ReversePipeline/ReverseConversionContext.cs index 12cb5d1c..3ba2ff9d 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ReversePipeline/ReverseConversionContext.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ReversePipeline/ReverseConversionContext.cs @@ -38,6 +38,12 @@ internal class ReverseConversionContext /// Set by ProcessLoopSubGraphs, read by ProcessBranchSubGraphs. /// public string? CurrentLoopbackTargetId { get; set; } + + /// + /// Maps loop node ID to the block name that contains the Loop statement. + /// Used during topology-based reverse walk to generate correct ToLoopCond arguments. + /// + public Dictionary LoopOwnerBlockNames { get; set; } = new(); } /// diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowStorageService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowStorageService.cs index 05017d29..4f7690b0 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowStorageService.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowStorageService.cs @@ -52,7 +52,7 @@ public async Task CreateWorkflowAsync(string name, string? descri UseBlockMode = true, BlockScriptSource = GetDefaultBlockScriptTemplate(), MainProgram = string.Empty, - HelperFunctions = [], + HelperFunctions = GetDefaultHelperFunctions(), VariableConstants = [], BlueprintData = null, }; @@ -218,10 +218,109 @@ private static async Task SaveKcsFileInternalAsync(string filePath, KcsFileForma } /// - /// Returns a default BlockScript template for new workflows + /// Returns a default BlockScript template for new workflows. + /// Includes a complete guessing game example demonstrating + /// ConstBlock, PubVarBlock, MainBlock, Loop, Branch, and custom blocks. /// private static string GetDefaultBlockScriptTemplate() { - return "#ConstBlock\n\n#PubVarBlock\n\n#MainBlock\nPrint(\"Hello, KitX Workflow!\");\n"; + return @"#ConstBlock +int guessNum = 5; +int loopMax = 3; +int targetNum = 7; +int currentLoop; + +#PubVarBlock +bool vaaa0001; +int vaaa0002; + +#MainBlock +Print(""Start Workflow""); +Set(""currentLoop"", 0); +NextBlock = ""LoopCond""; + +#Block LoopCond +vaaa0001 = HelperFuncCompare(""BLE"", Get(""currentLoop""), loopMax); +NextBlock = Loop(vaaa0001, ""LoopBody"", ""EndLogic""); + +#Block LoopBody +vaaa0002 = Get(""currentLoop""); +Print(vaaa0002); +Set(""currentLoop"", HelperFuncAdd(Get(""currentLoop""), 1)); +NextBlock = Branch( + HelperFuncCompare(""BEQ"", guessNum, targetNum), + ""SuccessLogic"", + ""CheckLogic"" +); + +#Block CheckLogic +NextBlock = Branch( + HelperFuncCompare(""BLT"", guessNum, targetNum), + ""LessThanLogic"", + ""GreaterThanLogic"" +); + +#Block LessThanLogic +Print(""Too small""); +NextBlock = ToLoopCond(""LoopCond""); + +#Block GreaterThanLogic +Print(""Too big""); +NextBlock = ToLoopCond(""LoopCond""); + +#Block SuccessLogic +Print(""Correct!""); + +#Block EndLogic +Print(""Workflow ended""); +"; + } + + /// + /// Returns default helper functions for new BlockScript workflows. + /// HelperFuncCompare: compares two numbers with operator string. + /// HelperFuncAdd: adds two integers. + /// + private static List GetDefaultHelperFunctions() + { + return + [ + new HelperFunction + { + Name = "HelperFuncCompare", + ReturnType = "bool", + Parameters = + [ + new HelperFunctionParameter { Name = "op", Type = "string" }, + new HelperFunctionParameter { Name = "value1", Type = "object?" }, + new HelperFunctionParameter { Name = "value2", Type = "object?" } + ], + Code = @"var v1 = Convert.ToDouble(value1); +var v2 = Convert.ToDouble(value2); +return op switch +{ + ""BEQ"" => v1 == v2, + ""BNE"" => v1 != v2, + ""BLT"" => v1 < v2, + ""BGT"" => v1 > v2, + ""BLE"" => v1 <= v2, + ""BGE"" => v1 >= v2, + _ => false +};" + }, + new HelperFunction + { + Name = "HelperFuncAdd", + ReturnType = "int", + Parameters = + [ + new HelperFunctionParameter { Name = "value1", Type = "object?" }, + new HelperFunctionParameter { Name = "value2", Type = "object?" } + ], + Code = @"var v1 = Convert.ToInt32(value1); +var v2 = Convert.ToInt32(value2); +return v1 + v2;" + } + ]; } } diff --git a/KitX Clients/KitX Dashboard b/KitX Clients/KitX Dashboard index 83526d0c..15af4fa4 160000 --- a/KitX Clients/KitX Dashboard +++ b/KitX Clients/KitX Dashboard @@ -1 +1 @@ -Subproject commit 83526d0ccbb108401ab92d1de4799ab4dd5d358b +Subproject commit 15af4fa4654b054de5fcaa6d36d8f0c10fcf13fb diff --git a/KitX Standard b/KitX Standard index 88f7e0ae..4c0b2049 160000 --- a/KitX Standard +++ b/KitX Standard @@ -1 +1 @@ -Subproject commit 88f7e0ae75fe93775e595966d11bfcd53477609a +Subproject commit 4c0b204999111ddc77f0b3ddf5aa40fa74784a64 From 5e00c85cd2789a8fd4858a3cfa0d23e7eb8ef766 Mon Sep 17 00:00:00 2001 From: StarInk Date: Sun, 12 Apr 2026 03:27:26 +0200 Subject: [PATCH 051/110] =?UTF-8?q?=F0=9F=90=9B=20Bug(Blueprint):=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20BP=E2=86=92BS=20=E8=BD=AC=E6=8D=A2?= =?UTF-8?q?=E4=B8=AD=E4=B8=8D=E5=8F=AF=E8=BE=BE=E8=8A=82=E7=82=B9=E6=9C=AA?= =?UTF-8?q?=E8=A2=AB=E6=8E=92=E9=99=A4=E7=9A=84=E9=97=AE=E9=A2=98=EF=BC=8C?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E4=BB=8E=20Entry=20=E5=87=BA=E5=8F=91?= =?UTF-8?q?=E7=9A=84=20BFS=20=E5=8F=AF=E8=BE=BE=E6=80=A7=E5=88=86=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Workflow/Blueprint/ExecutionFlowWalker.cs | 45 ++++++++++++++++++- KitX Clients/KitX Dashboard | 2 +- KitX Standard | 2 +- 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExecutionFlowWalker.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExecutionFlowWalker.cs index fe0a0ecd..59cb31b3 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExecutionFlowWalker.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExecutionFlowWalker.cs @@ -56,7 +56,8 @@ public void WalkExecutionFlow(ReverseConversionContext ctx) /// /// Generates statements from stored BlockScopes (BlockScopes-based path). /// Processes each scope, generates statements for its nodes, resolves control flow, - /// and inserts ToLoopCond statements. + /// and inserts ToLoopCond statements. Nodes not reachable from Entry via execution + /// edges are excluded. /// public void WalkFromBlockScopes(Contract.Workflow.Blueprint blueprint, ReverseConversionContext ctx) { @@ -64,6 +65,9 @@ public void WalkFromBlockScopes(Contract.Workflow.Blueprint blueprint, ReverseCo foreach (var scope in blueprint.BlockScopes) ctx.ScopesByName[scope.Name] = scope; + // Reachability filter: only include nodes reachable from Entry via exec edges + var reachableNodeIds = FindReachableNodeIds(blueprint, ctx); + // Generate statements from stored block membership foreach (var scope in blueprint.BlockScopes) { @@ -81,6 +85,9 @@ public void WalkFromBlockScopes(Contract.Workflow.Blueprint blueprint, ReverseCo foreach (var nodeId in scope.NodeIds) { + // Skip nodes not reachable from Entry via execution edges + if (!reachableNodeIds.Contains(nodeId)) continue; + if (!ctx.NodeById.TryGetValue(nodeId, out var node)) continue; var stmt = GenerateStatement(node, ctx); if (stmt != null) @@ -117,6 +124,42 @@ public void WalkFromBlockScopes(Contract.Workflow.Blueprint blueprint, ReverseCo DetectAndInsertLoopbackToLoopConds(blueprint, ctx); } + // ─── Reachability Analysis ────────────────────────────────────────── + + /// + /// Performs BFS from the Entry node following only execution output pins + /// to determine which nodes are reachable from Entry. Nodes not in the + /// returned set should be excluded from BS output. + /// + private static HashSet FindReachableNodeIds( + Contract.Workflow.Blueprint blueprint, ReverseConversionContext ctx) + { + var reachable = new HashSet(); + var entry = blueprint.Nodes.FirstOrDefault(n => n.NodeType == BlueprintNodeType.Entry); + if (entry == null) return reachable; + + var queue = new Queue(); + queue.Enqueue(entry); + reachable.Add(entry.Id); + + while (queue.Count > 0) + { + var current = queue.Dequeue(); + foreach (var pin in current.OutputPins) + { + if (pin.Type != PinType.Execution) continue; + var conn = ctx.ExecConnections.FirstOrDefault(c => c.SourcePinId == pin.Id); + if (conn == null) continue; + var target = blueprint.GetNodeById(conn.TargetNodeId); + if (target == null || reachable.Contains(target.Id)) continue; + reachable.Add(target.Id); + queue.Enqueue(target); + } + } + + return reachable; + } + // ─── Node Walking ──────────────────────────────────────────────────── private void WalkNode(BlueprintNode node, BlockDefinition currentBlock, diff --git a/KitX Clients/KitX Dashboard b/KitX Clients/KitX Dashboard index 15af4fa4..b51d863e 160000 --- a/KitX Clients/KitX Dashboard +++ b/KitX Clients/KitX Dashboard @@ -1 +1 @@ -Subproject commit 15af4fa4654b054de5fcaa6d36d8f0c10fcf13fb +Subproject commit b51d863e6405996926bf93b56cc7891aa094b04a diff --git a/KitX Standard b/KitX Standard index 4c0b2049..1ce4ddf7 160000 --- a/KitX Standard +++ b/KitX Standard @@ -1 +1 @@ -Subproject commit 4c0b204999111ddc77f0b3ddf5aa40fa74784a64 +Subproject commit 1ce4ddf7a8a8fcbf5761ef81772cd21804fffb49 From ca96469ea491667a13789ffca01f22e7348ca390 Mon Sep 17 00:00:00 2001 From: StarInk Date: Sun, 12 Apr 2026 04:50:29 +0200 Subject: [PATCH 052/110] =?UTF-8?q?=F0=9F=93=9D=20Chore(Submodules):=20Syn?= =?UTF-8?q?c?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- KitX Clients/KitX Dashboard | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/KitX Clients/KitX Dashboard b/KitX Clients/KitX Dashboard index b51d863e..0eab124c 160000 --- a/KitX Clients/KitX Dashboard +++ b/KitX Clients/KitX Dashboard @@ -1 +1 @@ -Subproject commit b51d863e6405996926bf93b56cc7891aa094b04a +Subproject commit 0eab124cda46477248e5773cc5c758d1a691b495 From 8f02dfc4a2138502ea9c6cc7c6b0af536c7dcaff Mon Sep 17 00:00:00 2001 From: StarInk Date: Mon, 13 Apr 2026 03:27:32 +0200 Subject: [PATCH 053/110] =?UTF-8?q?=F0=9F=92=BE=20Feat(Trigger):=20?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=20TriggerManager=20=E4=BB=A5=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E6=8F=92=E4=BB=B6=E8=A7=A6=E5=8F=91=E5=99=A8=EF=BC=8C?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=B7=A5=E4=BD=9C=E6=B5=81=E8=A7=A6=E5=8F=91?= =?UTF-8?q?=E4=B8=8E=E8=B7=AF=E7=94=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DI/CoreServiceCollectionExtensions.cs | 8 + .../KitX Core/KitX.Core/Event/EventNames.cs | 15 ++ .../Workflow/Blueprint/ExecutionFlowWalker.cs | 9 +- .../Workflow/Blueprint/NodeRegistry.cs | 1 + .../KitX.Core/Workflow/RealPluginManager.cs | 18 +- .../KitX.Core/Workflow/TriggerManager.cs | 159 ++++++++++++++++++ .../Workflow/WorkflowScriptService.cs | 108 ++++++++---- .../Workflow/WorkflowStorageService.cs | 92 +++++++++- KitX Clients/KitX Dashboard | 2 +- KitX SDK | 2 +- KitX Standard | 2 +- 11 files changed, 365 insertions(+), 51 deletions(-) create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/TriggerManager.cs diff --git a/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs b/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs index caeeadf3..a73ccdbf 100644 --- a/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs +++ b/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs @@ -258,6 +258,14 @@ public static IServiceCollection AddCoreServices(this IServiceCollection service return service; }); + // Trigger Manager + Log.Information("Registering TriggerManager..."); + services.AddSingleton(provider => + { + var service = TriggerManager.Instance; + return service; + }); + Log.Information("AddCoreServices completed."); return services; } diff --git a/KitX Clients/KitX Core/KitX.Core/Event/EventNames.cs b/KitX Clients/KitX Core/KitX.Core/Event/EventNames.cs index e8203f27..f26d54d2 100644 --- a/KitX Clients/KitX Core/KitX.Core/Event/EventNames.cs +++ b/KitX Clients/KitX Core/KitX.Core/Event/EventNames.cs @@ -129,4 +129,19 @@ public static class EventNames /// Workflow data saved event /// public const string WorkflowDataSaved = "WorkflowDataSaved"; + + /// + /// Trigger fired event + /// + public const string TriggerFired = "TriggerFired"; + + /// + /// Workflow triggered event (a workflow was started by a trigger) + /// + public const string WorkflowTriggered = "WorkflowTriggered"; + + /// + /// Workflow execution result event (success or failure) + /// + public const string WorkflowExecutionResult = "WorkflowExecutionResult"; } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExecutionFlowWalker.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExecutionFlowWalker.cs index 59cb31b3..bf5175f2 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExecutionFlowWalker.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExecutionFlowWalker.cs @@ -37,10 +37,11 @@ public ExecutionFlowWalker( /// public void WalkExecutionFlow(ReverseConversionContext ctx) { - var entryNode = ctx.Blueprint.Nodes.FirstOrDefault(n => n.NodeType == BlueprintNodeType.Entry); + var entryNode = ctx.Blueprint.Nodes.FirstOrDefault(n => + n.NodeType == BlueprintNodeType.Entry || n.NodeType == BlueprintNodeType.PluginTrigger); if (entryNode == null) { - Log.Warning("[ExecutionFlowWalker] No Entry node found"); + Log.Warning("[ExecutionFlowWalker] No Entry or PluginTrigger node found"); return; } @@ -135,7 +136,8 @@ private static HashSet FindReachableNodeIds( Contract.Workflow.Blueprint blueprint, ReverseConversionContext ctx) { var reachable = new HashSet(); - var entry = blueprint.Nodes.FirstOrDefault(n => n.NodeType == BlueprintNodeType.Entry); + var entry = blueprint.Nodes.FirstOrDefault(n => + n.NodeType == BlueprintNodeType.Entry || n.NodeType == BlueprintNodeType.PluginTrigger); if (entry == null) return reachable; var queue = new Queue(); @@ -258,6 +260,7 @@ private static FlowControlStatement CreateToLoopCondStatement(string? returnTo = switch (node.NodeType) { case BlueprintNodeType.Entry: + case BlueprintNodeType.PluginTrigger: return null; case BlueprintNodeType.Break: return new FlowControlStatement diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeRegistry.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeRegistry.cs index 84bf27be..99afaa40 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeRegistry.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeRegistry.cs @@ -23,6 +23,7 @@ public NodeRegistry() _typeMap = new Dictionary { [BlueprintNodeType.Entry] = typeof(EntryNode), + [BlueprintNodeType.PluginTrigger] = typeof(PluginTriggerNode), [BlueprintNodeType.Branch] = typeof(BranchNode), [BlueprintNodeType.Loop] = typeof(LoopNode), [BlueprintNodeType.Break] = typeof(BreakNode), diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/RealPluginManager.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/RealPluginManager.cs index 5cf8e902..7051ab4b 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/RealPluginManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/RealPluginManager.cs @@ -91,22 +91,22 @@ private void OnPluginMessageReceived(object? sender, PluginMessageReceivedEventA { try { - Log.Information($"[RealPluginManager] OnPluginMessageReceived called with message: {e.Message?.Substring(0, Math.Min(200, e.Message?.Length ?? 0))}..."); - if (e.Message is null) return; var kwc = JsonSerializer.Deserialize(e.Message, _serializerOptions); - if (kwc?.Content is null) - { - Log.Information($"[RealPluginManager] kwc or kwc.Content is null"); - return; - } - - Log.Information($"[RealPluginManager] kwc.Content: {kwc.Content.Substring(0, Math.Min(100, kwc.Content.Length))}..."); + if (kwc?.Content is null) return; var command = JsonSerializer.Deserialize(kwc.Content, _serializerOptions); if (command.Request is null) return; // Command is a struct, check if Request is empty + // 最早跳过 TriggerFired,由 TriggerManager 处理,不产生多余日志 + if (command.Request == CommandRequestInfo.TriggerFired) + return; + + // 仅对非 TriggerFired 消息记录日志 + Log.Information($"[RealPluginManager] OnPluginMessageReceived called with message: {e.Message.Substring(0, Math.Min(200, e.Message.Length))}..."); + Log.Information($"[RealPluginManager] kwc.Content: {kwc.Content.Substring(0, Math.Min(100, kwc.Content.Length))}..."); + // 检查是否是响应消息 if (command.Tags != null && command.Tags.TryGetValue("RequestId", out var requestId)) { diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/TriggerManager.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/TriggerManager.cs new file mode 100644 index 00000000..fb1b422e --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/TriggerManager.cs @@ -0,0 +1,159 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using KitX.Core.Contract.Workflow; +using KitX.Core.Device; +using KitX.Shared.CSharp.WebCommand; +using KitX.Shared.CSharp.WebCommand.Infos; +using Serilog; + +namespace KitX.Core.Workflow; + +/// +/// 管理触发器路由:从插件接收触发信号,路由到匹配的工作流并执行。 +/// 触发器是纯信号(等同于"运行"按钮),不携带业务数据。 +/// +public class TriggerManager +{ + private static TriggerManager? _instance; + + internal static TriggerManager Instance => _instance ??= new(); + + private readonly PluginsServer _pluginsServer; + private readonly JsonSerializerOptions _serializerOptions = new() + { + WriteIndented = true, + IncludeFields = true, + PropertyNameCaseInsensitive = true, + }; + + /// + /// 订阅索引:Key = "PluginName.TriggerName" 或 "PluginName.*",Value = workflow ID 列表 + /// + private Dictionary> _triggerSubscriptions = new(); + + /// + /// 工作流触发器配置:Key = workflowId, Value = TriggerConfig + /// + private readonly Dictionary _workflowTriggers = new(); + + private TriggerManager() + { + _pluginsServer = PluginsServer.Instance; + _pluginsServer.PluginMessageReceived += OnPluginMessageReceived; + Log.Information("[TriggerManager] Initialized and subscribed to PluginMessageReceived"); + } + + /// + /// 注册工作流的触发器配置 + /// + /// 工作流 ID + /// 触发器配置 + public void RegisterWorkflowTrigger(string workflowId, TriggerConfig config) + { + if (config.TriggerType != "PluginEvent" || string.IsNullOrEmpty(config.PluginName)) + return; + + _workflowTriggers[workflowId] = config; + RebuildSubscriptionIndex(); + + Log.Information("[TriggerManager] Registered trigger for workflow {WorkflowId}: " + + "PluginName={PluginName}, TriggerName={TriggerName}", + workflowId, config.PluginName, config.TriggerName); + } + + /// + /// 注销工作流的触发器 + /// + /// 工作流 ID + public void UnregisterWorkflowTrigger(string workflowId) + { + if (_workflowTriggers.Remove(workflowId)) + { + RebuildSubscriptionIndex(); + Log.Information("[TriggerManager] Unregistered trigger for workflow {WorkflowId}", workflowId); + } + } + + /// + /// 重建订阅索引(在配置变更时调用) + /// + private void RebuildSubscriptionIndex() + { + var newIndex = new Dictionary>(); + + foreach (var (workflowId, config) in _workflowTriggers) + { + if (config.TriggerType != "PluginEvent" || string.IsNullOrEmpty(config.PluginName)) + continue; + + var key = string.IsNullOrEmpty(config.TriggerName) + ? $"{config.PluginName}.*" + : $"{config.PluginName}.{config.TriggerName}"; + + if (!newIndex.ContainsKey(key)) + newIndex[key] = []; + newIndex[key].Add(workflowId); + } + + _triggerSubscriptions = newIndex; + } + + /// + /// 处理插件消息,识别 TriggerFired 并路由到匹配的工作流 + /// + private void OnPluginMessageReceived(object? sender, PluginMessageReceivedEventArgs e) + { + try + { + if (e.Message is null) return; + + var kwc = JsonSerializer.Deserialize(e.Message, _serializerOptions); + if (kwc?.Content is null) return; + + var command = JsonSerializer.Deserialize(kwc.Content, _serializerOptions); + if (command.Request != CommandRequestInfo.TriggerFired) return; + + // 查找发送此消息的插件名称 + var connectionId = e.ConnectionId; + var connection = _pluginsServer.Connections + .FirstOrDefault(c => c.ConnectionId == connectionId); + var pluginName = connection?.PluginInfo?.Name ?? "Unknown"; + + var triggerName = command.Tags?.TryGetValue("TriggerName", out var name) == true + ? name : "Unknown"; + + Log.Information("[TriggerManager] Trigger '{TriggerName}' fired by plugin '{PluginName}'", + triggerName, pluginName); + + // 查找匹配的工作流 + var specificKey = $"{pluginName}.{triggerName}"; + var wildcardKey = $"{pluginName}.*"; + + var matchingWorkflowIds = new HashSet(); + if (_triggerSubscriptions.TryGetValue(specificKey, out var specific)) + foreach (var id in specific) matchingWorkflowIds.Add(id); + if (_triggerSubscriptions.TryGetValue(wildcardKey, out var wildcard)) + foreach (var id in wildcard) matchingWorkflowIds.Add(id); + + foreach (var workflowId in matchingWorkflowIds) + { + Log.Information("[TriggerManager] Triggering workflow: {WorkflowId}", workflowId); + _ = System.Threading.Tasks.Task.Run(async () => + { + bool success = await WorkflowScriptService.Instance.RunWorkflowAsync(workflowId); + KitX.Core.Event.EventService.Instance.Publish( + KitX.Core.Event.EventNames.WorkflowExecutionResult, + new KitX.Core.Contract.Event.WorkflowExecutionResultEventArgs( + workflowId, success, + success ? null : "Workflow execution failed")); + }); + } + } + catch (Exception ex) + { + Log.Warning(ex, "[TriggerManager] Error processing trigger message"); + } + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs index e5900dda..a9fb1cfa 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs @@ -87,7 +87,7 @@ public IReadOnlyList GetWorkflows() } /// - /// Runs a workflow + /// Runs a workflow by loading its data from storage and executing the BlockScript source. /// /// The workflow ID /// True if run was successful @@ -97,38 +97,78 @@ public async Task RunWorkflowAsync(string workflowId) try { - var workflow = _workflows.FirstOrDefault(w => w.Id == workflowId); + // Load workflow data from storage (not from the stale in-memory list) + var storageService = WorkflowStorageService.Instance; + var data = await storageService.LoadWorkflowDataAsync(workflowId); - if (workflow == null) + if (data == null) { - Log.Warning($"Workflow not found: {workflowId}"); - return await System.Threading.Tasks.Task.FromResult(false); + Log.Warning("[{Location}] Workflow data not found in storage for ID: {WorkflowId}. " + + "Expected file path: {Path}", location, workflowId, + storageService.GetWorkflowFilePath(workflowId)); + return false; } - if (workflow.IsRunning) + Log.Information("[{Location}] Loaded workflow '{Name}' (ID: {Id}), " + + "UseBlockMode: {UseBlockMode}, BlockScriptSource length: {BsLen}, " + + "MainProgram length: {MpLen}, Helpers: {HelperCount}", + location, data.Name, workflowId, data.UseBlockMode, + data.BlockScriptSource?.Length ?? 0, + data.MainProgram?.Length ?? 0, + data.HelperFunctions?.Count ?? 0); + + // Determine the source code to execute + string sourceCode; + List? helpers = data.HelperFunctions; + + if (data.UseBlockMode && !string.IsNullOrWhiteSpace(data.BlockScriptSource)) { - Log.Warning($"Workflow is already running: {workflow.Name}"); - return await System.Threading.Tasks.Task.FromResult(false); + sourceCode = data.BlockScriptSource; + } + else if (!string.IsNullOrWhiteSpace(data.MainProgram)) + { + sourceCode = data.MainProgram; + } + else + { + Log.Warning("[{Location}] Workflow '{Name}' (ID: {Id}) has no executable source code", + location, data.Name, workflowId); + return false; } - workflow.IsRunning = true; + // Execute the BlockScript + Log.Information("[{Location}] Executing workflow '{Name}' ({SourceLength} chars)...", + location, data.Name, sourceCode.Length); - // TODO: Implement actual workflow execution logic - // This would involve parsing the workflow script and executing it + var result = await ExecuteBlockScriptAsync(sourceCode, helpers ?? [], CancellationToken.None); - Log.Information($"Started workflow: {workflow.Name}"); + if (result.IsSuccess) + { + var output = result.Output != null && result.Output.Count > 0 + ? string.Join("\n", result.Output) + : "(no output)"; + Log.Information("[{Location}] Workflow '{Name}' executed successfully. " + + "Blocks: {Blocks}, Time: {Time}ms\nOutput:\n{Output}", + location, data.Name, result.ExecutedBlockCount, result.ExecutionTimeMs, output); + } + else + { + Log.Error("[{Location}] Workflow '{Name}' execution failed: {Error}", + location, data.Name, result.ErrorMessage); + } - return await System.Threading.Tasks.Task.FromResult(true); + return result.IsSuccess; } catch (Exception ex) { - Log.Error(ex, $"In {location}: Error running workflow {workflowId}: {ex.Message}"); - return await System.Threading.Tasks.Task.FromResult(false); + Log.Error(ex, "[{Location}] Error running workflow {WorkflowId}: {Message}", + location, workflowId, ex.Message); + return false; } } /// - /// Stops a workflow + /// Stops a running workflow by cancelling its execution. /// /// The workflow ID /// True if stop was successful @@ -138,32 +178,28 @@ public async Task StopWorkflowAsync(string workflowId) try { - var workflow = _workflows.FirstOrDefault(w => w.Id == workflowId); + var storageService = WorkflowStorageService.Instance; + var data = await storageService.LoadWorkflowDataAsync(workflowId); - if (workflow == null) + if (data == null) { - Log.Warning($"Workflow not found: {workflowId}"); - return await System.Threading.Tasks.Task.FromResult(false); + Log.Warning("[{Location}] Workflow data not found in storage for ID: {WorkflowId}", + location, workflowId); + return false; } - if (!workflow.IsRunning) - { - Log.Warning($"Workflow is not running: {workflow.Name}"); - return await System.Threading.Tasks.Task.FromResult(false); - } + // TODO: Implement actual cancellation via CancellationTokenSource tracking + Log.Information("[{Location}] Stop requested for workflow '{Name}' (ID: {WorkflowId}) — " + + "cancellation not yet implemented, workflow will complete current execution", + location, data.Name, workflowId); - workflow.IsRunning = false; - - // TODO: Implement actual workflow stopping logic - - Log.Information($"Stopped workflow: {workflow.Name}"); - - return await System.Threading.Tasks.Task.FromResult(true); + return true; } catch (Exception ex) { - Log.Error(ex, $"In {location}: Error stopping workflow {workflowId}: {ex.Message}"); - return await System.Threading.Tasks.Task.FromResult(false); + Log.Error(ex, "[{Location}] Error stopping workflow {WorkflowId}: {Message}", + location, workflowId, ex.Message); + return false; } } @@ -992,8 +1028,12 @@ public class WorkflowCase : IWorkflowCase public string Description { get; set; } = string.Empty; public string Author { get; set; } = string.Empty; public bool IsRunning { get; set; } + public bool IsError { get; set; } + public string? ErrorMessage { get; set; } public string? ScriptPath { get; set; } public DateTime CreatedTime { get; set; } = DateTime.UtcNow; public DateTime LastModifiedTime { get; set; } = DateTime.UtcNow; public string TriggerType { get; set; } = "Manual"; + + public TriggerConfig? TriggerConfig { get; set; } } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowStorageService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowStorageService.cs index 4f7690b0..9ec68060 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowStorageService.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowStorageService.cs @@ -157,6 +157,11 @@ public async Task> DiscoverWorkflowsAsync() ? File.GetCreationTimeUtc(file) : kcs.CreatedTime; + // Prefer TriggerConfig (structured) over legacy TriggerType (string) + var triggerConfig = kcs.TriggerConfig; + var triggerType = triggerConfig?.TriggerType + ?? (string.IsNullOrEmpty(kcs.TriggerType) ? "Manual" : kcs.TriggerType); + results.Add(new WorkflowCase { Id = id, @@ -167,7 +172,8 @@ public async Task> DiscoverWorkflowsAsync() ScriptPath = file, CreatedTime = createdTime, LastModifiedTime = kcs.LastModifiedTime == default ? File.GetLastWriteTimeUtc(file) : kcs.LastModifiedTime, - TriggerType = string.IsNullOrEmpty(kcs.TriggerType) ? "Manual" : kcs.TriggerType, + TriggerType = triggerType, + TriggerConfig = triggerConfig, }); } catch (Exception ex) @@ -202,7 +208,21 @@ private void EnsureDirectoryExists() try { var json = await File.ReadAllTextAsync(filePath); - return JsonSerializer.Deserialize(json, _jsonOptions); + + // First pass: try full deserialization (including BlueprintData) + try + { + return JsonSerializer.Deserialize(json, _jsonOptions); + } + catch (NotSupportedException ex) when (ex.Message.Contains("type discriminator")) + { + // BlueprintData has nodes without $type discriminators — retry without BP data + Log.Warning(ex, + "[WorkflowStorageService] BlueprintData deserialization failed for {FilePath}. " + + "Retrying without BlueprintData (legacy format or missing type discriminators)", + filePath); + return LoadKcsFileResilient(json, filePath); + } } catch (Exception ex) { @@ -211,6 +231,74 @@ private void EnsureDirectoryExists() } } + /// + /// Fallback loader: manually extracts non-BlueprintData fields from JSON. + /// Used when full deserialization fails due to polymorphic BlueprintNode issues. + /// + private static KcsFileFormat? LoadKcsFileResilient(string json, string filePath) + { + try + { + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + var result = new KcsFileFormat + { + // Intentionally skip BlueprintData — it's the field causing the failure + BlueprintData = null, + }; + + if (root.TryGetProperty(nameof(KcsFileFormat.Id), out var id)) + result.Id = id.GetString() ?? string.Empty; + if (root.TryGetProperty(nameof(KcsFileFormat.Name), out var name)) + result.Name = name.GetString() ?? string.Empty; + if (root.TryGetProperty(nameof(KcsFileFormat.Description), out var desc)) + result.Description = desc.GetString() ?? string.Empty; + if (root.TryGetProperty(nameof(KcsFileFormat.Author), out var author)) + result.Author = author.GetString() ?? string.Empty; + if (root.TryGetProperty(nameof(KcsFileFormat.TriggerType), out var triggerType)) + result.TriggerType = triggerType.GetString() ?? "Manual"; + if (root.TryGetProperty(nameof(KcsFileFormat.UseBlockMode), out var useBlockMode)) + result.UseBlockMode = useBlockMode.GetBoolean(); + if (root.TryGetProperty(nameof(KcsFileFormat.BlockScriptSource), out var bsSource)) + result.BlockScriptSource = bsSource.GetString(); + if (root.TryGetProperty(nameof(KcsFileFormat.MainProgram), out var mainProg)) + result.MainProgram = mainProg.GetString() ?? string.Empty; + + // Deserialize complex sub-objects + if (root.TryGetProperty(nameof(KcsFileFormat.HelperFunctions), out var helpers)) + result.HelperFunctions = JsonSerializer.Deserialize>( + helpers.GetRawText(), _jsonOptions) ?? []; + + if (root.TryGetProperty(nameof(KcsFileFormat.VariableConstants), out var vars)) + result.VariableConstants = JsonSerializer.Deserialize>( + vars.GetRawText(), _jsonOptions) ?? []; + + if (root.TryGetProperty(nameof(KcsFileFormat.TriggerConfig), out var triggerConfig)) + result.TriggerConfig = JsonSerializer.Deserialize( + triggerConfig.GetRawText(), _jsonOptions); + + if (root.TryGetProperty(nameof(KcsFileFormat.CreatedTime), out var createdTime)) + result.CreatedTime = createdTime.GetDateTime(); + + if (root.TryGetProperty(nameof(KcsFileFormat.LastModifiedTime), out var modTime)) + result.LastModifiedTime = modTime.GetDateTime(); + + Log.Information( + "[WorkflowStorageService] Resilient load succeeded for {FilePath} " + + "(BlueprintData skipped, BlockScriptSource={BsLen} chars)", + filePath, result.BlockScriptSource?.Length ?? 0); + + return result; + } + catch (Exception ex) + { + Log.Error(ex, + "[WorkflowStorageService] Resilient load also failed for {FilePath}", filePath); + return null; + } + } + private static async Task SaveKcsFileInternalAsync(string filePath, KcsFileFormat data) { var json = JsonSerializer.Serialize(data, _jsonOptions); diff --git a/KitX Clients/KitX Dashboard b/KitX Clients/KitX Dashboard index 0eab124c..a333b99d 160000 --- a/KitX Clients/KitX Dashboard +++ b/KitX Clients/KitX Dashboard @@ -1 +1 @@ -Subproject commit 0eab124cda46477248e5773cc5c758d1a691b495 +Subproject commit a333b99d229e71e7daa761cde39e47faad4c977e diff --git a/KitX SDK b/KitX SDK index 1539dcbc..73bfe588 160000 --- a/KitX SDK +++ b/KitX SDK @@ -1 +1 @@ -Subproject commit 1539dcbc18f13c91972fed4015eb0658296044b2 +Subproject commit 73bfe588f821e76984059cd3d4d5df95ec0a6ef0 diff --git a/KitX Standard b/KitX Standard index 1ce4ddf7..aeb466d2 160000 --- a/KitX Standard +++ b/KitX Standard @@ -1 +1 @@ -Subproject commit 1ce4ddf7a8a8fcbf5761ef81772cd21804fffb49 +Subproject commit aeb466d2b996d865204cd5d0d6d1f6cbde789df5 From bd4593f08a2d40199462d205490b511f5ed287b1 Mon Sep 17 00:00:00 2001 From: StarInk Date: Tue, 14 Apr 2026 02:17:43 +0200 Subject: [PATCH 054/110] =?UTF-8?q?=F0=9F=A7=A9=F0=9F=92=BE=20Refactor,=20?= =?UTF-8?q?Feat(Blueprint,=20BlockScript):=20=E5=BC=95=E5=85=A5=20CFG=20?= =?UTF-8?q?=E6=8E=A7=E5=88=B6=E6=B5=81=E5=9B=BE=E9=87=8D=E6=9E=84=20BS?= =?UTF-8?q?=E2=86=94BP=20=E8=BD=AC=E6=8D=A2=E7=AE=A1=E7=BA=BF=EF=BC=8C?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E5=9D=97=E7=BA=A7=E9=A2=84=E7=BC=96=E8=AF=91?= =?UTF-8?q?=E6=89=A7=E8=A1=8C=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BP→BS 反向转换改用 CFG 四阶段管线(CFGBuilder→ConditionDuplicator→ScriptGenerator→Serializer), 替代旧的 ReversePipeline 逐块拼接方式,保证正反转换一致性;修复 VariableNode 丢失与 HelperFunctions 未传递的往返问题。新增 BlockCompiler 将每个块预编译为单段 CSharpScript 代码,遇到流程控制语句截断 死代码并自动补全隐式 NextBlock,执行器从逐条 ContinueWithAsync 改为按块单次调用,性能提升约 2-10x(取决于单块内语句数量)。 --- .../KitX.Core.BluePrint.Test/Program.cs | 197 ++- .../Workflow/BlockScripting/BlockCompiler.cs | 235 ++++ .../BlockScripting/BlockScriptExecutor.cs | 198 ++- .../BlockScripting/BlockStatementExtractor.cs | 23 +- .../BlueprintToBlockScriptConverter.cs | 82 +- .../Workflow/Blueprint/CFG/CFGBlock.cs | 82 ++ .../Blueprint/CFG/CFGBuilderFromBlueprint.cs | 1161 +++++++++++++++++ .../Blueprint/CFG/CFGBuilderFromScript.cs | 295 +++++ .../Blueprint/CFG/CFGConditionDuplicator.cs | 97 ++ .../Workflow/Blueprint/CFG/CFGEdge.cs | 78 ++ .../Workflow/Blueprint/CFG/CFGStatement.cs | 155 +++ .../Blueprint/CFG/ConstDeclaration.cs | 34 + .../Blueprint/CFG/ControlFlowGraph.cs | 91 ++ .../Workflow/Blueprint/CFG/ScriptGenerator.cs | 192 +++ .../Blueprint/CFG/ScriptSerializer.cs | 119 ++ ...versionContext.cs => ConversionContext.cs} | 16 +- .../Workflow/Blueprint/ExecutionFlowWalker.cs | 667 ---------- .../Workflow/Blueprint/NodeExportHelper.cs | 19 +- .../Blueprint/Pipeline/DataEdgeBuilder.cs | 7 + .../Workflow/Blueprint/Pipeline/ExprUtils.cs | 38 + .../ReversePipeline/BlockScriptAssembler.cs | 84 -- .../ReversePipeline/BlueprintAnalyzer.cs | 113 -- .../ReversePipeline/ConditionDuplicator.cs | 128 -- 23 files changed, 2956 insertions(+), 1155 deletions(-) create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockCompiler.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGBlock.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGBuilderFromBlueprint.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGBuilderFromScript.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGConditionDuplicator.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGEdge.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGStatement.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/ConstDeclaration.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/ControlFlowGraph.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/ScriptGenerator.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/ScriptSerializer.cs rename KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/{ReversePipeline/ReverseConversionContext.cs => ConversionContext.cs} (83%) delete mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExecutionFlowWalker.cs delete mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ReversePipeline/BlockScriptAssembler.cs delete mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ReversePipeline/BlueprintAnalyzer.cs delete mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ReversePipeline/ConditionDuplicator.cs diff --git a/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs b/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs index 4e7b8d06..e7f87214 100644 --- a/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs +++ b/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs @@ -6,16 +6,28 @@ using KitX.Core.DI; using KitX.Core.Contract.Workflow; using KitX.Core.Workflow.Blueprint; +using KitX.Core.Workflow.Blueprint.CFG; using KitX.Core.Workflow.BlockScripting; namespace KitX.Core.BluePrint.Test; public class Program { + // Test selection: --test A,D,K or --test all (default: all) + private static HashSet _selectedTests = new(StringComparer.OrdinalIgnoreCase) { "ALL" }; + private static bool _dumpCfg = false; + public static void Main(string[] args) { + ParseArgs(args); + Console.WriteLine("=== KitX BlockScript → Blueprint Pipeline Test ===\n"); + if (_selectedTests.Contains("ALL")) + Console.WriteLine("Running all tests.\n"); + else + Console.WriteLine($"Running tests: {string.Join(", ", _selectedTests)}\n"); + // DI Container var services = new ServiceCollection(); services.AddCoreServices(); @@ -57,84 +69,115 @@ public static void Main(string[] args) var funcRegistry = sp.GetRequiredService(); var converter = new BlockScriptToBlueprintConverter(parser, nodeRegistry, layoutService, funcRegistry); - // ── Test A: Pre-expanded format (already in test script) ── - Console.WriteLine("┌──────────────────────────────────────────┐"); - Console.WriteLine("│ Test A: Pre-expanded BlockScript │"); - Console.WriteLine("└──────────────────────────────────────────┘\n"); - - RunTest(converter, GetPreExpandedScript(), helpers, "Test A"); - - // ── Test B: Raw nested format ── - Console.WriteLine("\n┌──────────────────────────────────────────┐"); - Console.WriteLine("│ Test B: Raw nested BlockScript │"); - Console.WriteLine("└──────────────────────────────────────────┘\n"); - - RunTest(converter, GetRawNestedScript(), helpers, "Test B"); - - // ── Test C: Reverse conversion (Blueprint → Expanded Script) ── - Console.WriteLine("\n┌──────────────────────────────────────────┐"); - Console.WriteLine("│ Test C: Blueprint → Expanded Script │"); - Console.WriteLine("└──────────────────────────────────────────┘\n"); - - RunReverseTest(converter, reverseConverter, GetRawNestedScript(), helpers, "Test C"); - - // ── Test D: Round-trip consistency test ── - Console.WriteLine("\n┌──────────────────────────────────────────┐"); - Console.WriteLine("│ Test D: Round-trip consistency │"); - Console.WriteLine("└──────────────────────────────────────────┘\n"); - - RunRoundTripTest(converter, reverseConverter, GetRawNestedScript(), helpers, "Test D"); - - // ── Test E: Manual Blueprint construction test ── - Console.WriteLine("\n┌──────────────────────────────────────────┐"); - Console.WriteLine("│ Test E: Manual Blueprint → Script │"); - Console.WriteLine("└──────────────────────────────────────────┘\n"); - - RunManualBlueprintTest(reverseConverter, "Test E"); - - // ── Test F: Pure sequential flow (no Branch/Loop) ── - Console.WriteLine("\n┌──────────────────────────────────────────┐"); - Console.WriteLine("│ Test F: Pure sequential flow │"); - Console.WriteLine("└──────────────────────────────────────────┘\n"); + if (ShouldRunTest("A")) + { + Console.WriteLine("┌──────────────────────────────────────────┐"); + Console.WriteLine("│ Test A: Pre-expanded BlockScript │"); + Console.WriteLine("└──────────────────────────────────────────┘\n"); + RunTest(converter, GetPreExpandedScript(), helpers, "Test A"); + } - RunTest(converter, GetSequentialScript(), helpers, "Test F"); + if (ShouldRunTest("B")) + { + Console.WriteLine("\n┌──────────────────────────────────────────┐"); + Console.WriteLine("│ Test B: Raw nested BlockScript │"); + Console.WriteLine("└──────────────────────────────────────────┘\n"); + RunTest(converter, GetRawNestedScript(), helpers, "Test B"); + } - // ── Test G: No ConstBlock ── - Console.WriteLine("\n┌──────────────────────────────────────────┐"); - Console.WriteLine("│ Test G: No ConstBlock │"); - Console.WriteLine("└──────────────────────────────────────────┘\n"); + if (ShouldRunTest("C")) + { + Console.WriteLine("\n┌──────────────────────────────────────────┐"); + Console.WriteLine("│ Test C: Blueprint → Expanded Script │"); + Console.WriteLine("└──────────────────────────────────────────┘\n"); + RunReverseTest(converter, reverseConverter, GetRawNestedScript(), helpers, "Test C"); + } - RunTest(converter, GetNoConstScript(), helpers, "Test G"); + if (ShouldRunTest("D")) + { + Console.WriteLine("\n┌──────────────────────────────────────────┐"); + Console.WriteLine("│ Test D: Round-trip consistency │"); + Console.WriteLine("└──────────────────────────────────────────┘\n"); + RunRoundTripTest(converter, reverseConverter, GetRawNestedScript(), helpers, "Test D"); + } - // ── Test H: Break inside Loop ── - Console.WriteLine("\n┌──────────────────────────────────────────┐"); - Console.WriteLine("│ Test H: Break inside Loop │"); - Console.WriteLine("└──────────────────────────────────────────┘\n"); + if (ShouldRunTest("E")) + { + Console.WriteLine("\n┌──────────────────────────────────────────┐"); + Console.WriteLine("│ Test E: Manual Blueprint → Script │"); + Console.WriteLine("└──────────────────────────────────────────┘\n"); + RunManualBlueprintTest(reverseConverter, "Test E"); + } - RunRoundTripTest(converter, reverseConverter, GetBreakScript(), helpers, "Test H"); + if (ShouldRunTest("F")) + { + Console.WriteLine("\n┌──────────────────────────────────────────┐"); + Console.WriteLine("│ Test F: Pure sequential flow │"); + Console.WriteLine("└──────────────────────────────────────────┘\n"); + RunTest(converter, GetSequentialScript(), helpers, "Test F"); + } - // ── Test I: Nested Loop ── - Console.WriteLine("\n┌──────────────────────────────────────────┐"); - Console.WriteLine("│ Test I: Nested Loop │"); - Console.WriteLine("└──────────────────────────────────────────┘\n"); + if (ShouldRunTest("G")) + { + Console.WriteLine("\n┌──────────────────────────────────────────┐"); + Console.WriteLine("│ Test G: No ConstBlock │"); + Console.WriteLine("└──────────────────────────────────────────┘\n"); + RunTest(converter, GetNoConstScript(), helpers, "Test G"); + } - RunRoundTripTest(converter, reverseConverter, GetNestedLoopScript(), helpers, "Test I"); + if (ShouldRunTest("H")) + { + Console.WriteLine("\n┌──────────────────────────────────────────┐"); + Console.WriteLine("│ Test H: Break inside Loop │"); + Console.WriteLine("└──────────────────────────────────────────┘\n"); + RunRoundTripTest(converter, reverseConverter, GetBreakScript(), helpers, "Test H"); + } - // ── Test J: Single statement ── - Console.WriteLine("\n┌──────────────────────────────────────────┐"); - Console.WriteLine("│ Test J: Single statement (minimal) │"); - Console.WriteLine("└──────────────────────────────────────────┘\n"); + if (ShouldRunTest("I")) + { + Console.WriteLine("\n┌──────────────────────────────────────────┐"); + Console.WriteLine("│ Test I: Nested Loop │"); + Console.WriteLine("└──────────────────────────────────────────┘\n"); + RunRoundTripTest(converter, reverseConverter, GetNestedLoopScript(), helpers, "Test I"); + } - RunTest(converter, GetSingleStatementScript(), helpers, "Test J"); + if (ShouldRunTest("J")) + { + Console.WriteLine("\n┌──────────────────────────────────────────┐"); + Console.WriteLine("│ Test J: Single statement (minimal) │"); + Console.WriteLine("└──────────────────────────────────────────┘\n"); + RunTest(converter, GetSingleStatementScript(), helpers, "Test J"); + } - // ── Test K: Forward → Reverse → Execute round-trip ── - Console.WriteLine("\n┌──────────────────────────────────────────┐"); - Console.WriteLine("│ Test K: Forward → Reverse → Execute │"); - Console.WriteLine("└──────────────────────────────────────────┘\n"); + if (ShouldRunTest("K")) + { + Console.WriteLine("\n┌──────────────────────────────────────────┐"); + Console.WriteLine("│ Test K: Forward → Reverse → Execute │"); + Console.WriteLine("└──────────────────────────────────────────┘\n"); + RunExecutionTest(converter, reverseConverter, parser, sp, GetRawNestedScript(), helpers, "Test K"); + } + } - RunExecutionTest(converter, reverseConverter, parser, sp, GetRawNestedScript(), helpers, "Test K"); + private static void ParseArgs(string[] args) + { + for (int i = 0; i < args.Length; i++) + { + if ((args[i] == "--test" || args[i] == "-t") && i + 1 < args.Length) + { + _selectedTests = new HashSet( + args[++i].Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries), + StringComparer.OrdinalIgnoreCase); + } + else if (args[i] == "--cfg" || args[i] == "-c") + { + _dumpCfg = true; + } + } } + private static bool ShouldRunTest(string testId) => + _selectedTests.Contains("ALL") || _selectedTests.Contains(testId); + private static void RunTest(BlockScriptToBlueprintConverter converter, string sourceCode, List helpers, string label) { @@ -468,6 +511,14 @@ private static void RunReverseTest( // Step 2: Reverse convert var expandedScript = reverseConverter.Convert(blueprint); + // Dump CFG if requested + if (_dumpCfg && reverseConverter is BlueprintToBlockScriptConverter cfgConv && cfgConv.LastCFG != null) + { + Console.WriteLine($"\n ── CFG Dump ({label}) ──"); + Console.WriteLine(cfgConv.LastCFG.Dump()); + Console.WriteLine($" ── End CFG Dump ({label}) ──"); + } + Console.WriteLine($"\n ── Expanded Script ({label}) ──"); Console.WriteLine(expandedScript); Console.WriteLine($" ── End Expanded Script ({label}) ──\n"); @@ -497,12 +548,28 @@ private static void RunRoundTripTest( // Blueprint → Expanded Script var script1 = reverseConverter.Convert(bp1); + // Dump CFG for round 1 if requested + if (_dumpCfg && reverseConverter is BlueprintToBlockScriptConverter cfgConv1 && cfgConv1.LastCFG != null) + { + Console.WriteLine($"\n ── CFG Dump (Round 1) ──"); + Console.WriteLine(cfgConv1.LastCFG.Dump()); + Console.WriteLine($" ── End CFG Dump (Round 1) ──"); + } + // Expanded Script → Blueprint (second round) var bp2 = forwardConverter.Convert(script1, helpers); // Blueprint → Expanded Script (second round) var script2 = reverseConverter.Convert(bp2); + // Dump CFG for round 2 if requested + if (_dumpCfg && reverseConverter is BlueprintToBlockScriptConverter cfgConv2 && cfgConv2.LastCFG != null) + { + Console.WriteLine($"\n ── CFG Dump (Round 2) ──"); + Console.WriteLine(cfgConv2.LastCFG.Dump()); + Console.WriteLine($" ── End CFG Dump (Round 2) ──"); + } + // Compare Console.WriteLine($" Round 1 nodes: {bp1.Nodes.Count}, connections: {bp1.Connections.Count}"); Console.WriteLine($" Round 2 nodes: {bp2.Nodes.Count}, connections: {bp2.Connections.Count}"); diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockCompiler.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockCompiler.cs new file mode 100644 index 00000000..5dbb2537 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockCompiler.cs @@ -0,0 +1,235 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text; +using KitX.Core.Contract.Workflow; +using Microsoft.CodeAnalysis.CSharp; +using Serilog; + +namespace KitX.Core.Workflow.BlockScripting; + +/// +/// Compiles a into a single CSharpScript code string, +/// enabling block-level precompilation instead of per-statement evaluation. +/// +/// Key design principles based on BlockScript grammar: +/// +/// Flow control statements (Branch/Loop/ToLoopCond/Break/Return) always terminate +/// a block — any code after them is dead code and will be truncated with a warning. +/// NextBlock = "BlockName" is executable code that sets +/// at runtime, not just declarative +/// metadata. It is preserved in compiled output. +/// When a block has no explicit NextBlock assignment and no flow control +/// terminator, the compiler auto-completes NextBlock = "nextBlockName"; from +/// . +/// +/// +internal class BlockCompiler +{ + /// + /// Compiles a into a single CSharpScript code string. + /// + /// The block to compile. + /// + /// Variables already declared in the initialization script (e.g. ConstBlock/PubVarBlock variables). + /// These are omitted from re-declaration — the block only emits assignments for them. + /// + /// + /// The compiled CSharpScript code string, or null if the block is empty + /// and has no NextBlock to auto-complete. + /// + public string? CompileBlock(BlockDefinition block, HashSet predeclaredVariables) + { + var sb = new StringBuilder(); + + foreach (var statement in block.Statements) + { + switch (statement) + { + case ExpressionStatement exprStmt: + AppendExpression(sb, exprStmt, predeclaredVariables); + break; + + case VariableDeclarationStatement varStmt: + AppendVariableDeclaration(sb, varStmt, predeclaredVariables); + break; + + case FlowControlStatement flowStmt: + // Flow control terminates the block — append it and stop + AppendFlowControl(sb, flowStmt); + Log.Debug("[BlockCompiler] Block '{BlockName}': flow control ({ControlType}) " + + "terminates block at statement index, truncating any dead code after it", + block.Name, flowStmt.ControlType); + return Finalize(sb, block); + + default: + Log.Warning("[BlockCompiler] Block '{BlockName}': unknown statement type {Type} at line {Line}, skipping", + block.Name, statement.GetType().Name, statement.LineNumber); + break; + } + } + + // No flow control statement found — check if we need auto-completed NextBlock + return Finalize(sb, block); + } + + /// + /// Appends an expression statement to the code builder. + /// + private static void AppendExpression(StringBuilder sb, ExpressionStatement exprStmt, + HashSet predeclaredVariables) + { + var expr = exprStmt.Expression; + if (string.IsNullOrWhiteSpace(expr)) + return; + + // Handle NextBlock = "BlockName" — it's executable, keep it + // (the parser already extracts NextBlockName from plain string assignments, + // but the SourceCode preserves the original assignment expression for execution) + sb.AppendLine(expr + ";"); + } + + /// + /// Appends a variable declaration to the code builder. + /// For variables already declared in the init script, only the assignment is emitted. + /// + private static void AppendVariableDeclaration(StringBuilder sb, VariableDeclarationStatement varStmt, + HashSet predeclaredVariables) + { + var decl = varStmt.Declaration; + + if (predeclaredVariables.Contains(decl.Name)) + { + // Variable already declared in init script — just assign + if (!string.IsNullOrEmpty(decl.InitialValueExpression)) + { + sb.AppendLine($"{decl.Name} = {decl.InitialValueExpression};"); + } + else if (decl.DefaultValue != null) + { + var literal = FormatLiteral(decl.Type, decl.DefaultValue); + sb.AppendLine($"{decl.Name} = {literal};"); + } + // else: uninitialized predeclared variable — nothing to emit (already declared as dynamic) + } + else + { + // Not predeclared — need a declaration + if (!string.IsNullOrEmpty(decl.InitialValueExpression)) + { + sb.AppendLine($"var {decl.Name} = {decl.InitialValueExpression};"); + } + else if (decl.DefaultValue != null) + { + var literal = FormatLiteral(decl.Type, decl.DefaultValue); + sb.AppendLine($"var {decl.Name} = {literal};"); + } + else + { + // Uninitialized — declare with explicit type + sb.AppendLine($"{decl.Type} {decl.Name};"); + } + } + } + + /// + /// Appends a flow control statement to the code builder. + /// The SourceCode already contains the executable expression like + /// NextBlock = Loop(cond, "trueBlock", "falseBlock"); + /// + private static void AppendFlowControl(StringBuilder sb, FlowControlStatement flowStmt) + { + var source = flowStmt.SourceCode; + if (string.IsNullOrWhiteSpace(source)) + { + // Regenerate from fields if SourceCode is empty + flowStmt.RegenerateSourceCode(); + source = flowStmt.SourceCode; + } + + // Ensure it ends with semicolon + if (!source.TrimEnd().EndsWith(";")) + source = source.TrimEnd() + ";"; + + sb.AppendLine(source); + } + + /// + /// Finalizes the compiled block code: applies plugin call preprocessing + /// and auto-completes NextBlock if needed. + /// + private string? Finalize(StringBuilder sb, BlockDefinition block) + { + var code = sb.ToString(); + if (string.IsNullOrWhiteSpace(code) && string.IsNullOrEmpty(block.NextBlockName)) + { + // Empty block with no NextBlock — nothing to execute + return null; + } + + // Auto-complete NextBlock if the block has no explicit NextBlock assignment + // and no flow control terminator + if (!string.IsNullOrEmpty(block.NextBlockName) && !HasNextBlockAssignment(code)) + { + sb.AppendLine($"NextBlock = \"{block.NextBlockName}\";"); + code = sb.ToString(); + } + + // Apply plugin call preprocessing once on the entire block + code = PreProcessPluginCalls(code); + + return code; + } + + /// + /// Checks if the compiled code already contains a NextBlock assignment. + /// This includes both explicit assignments (NextBlock = "...";) and + /// flow control calls (NextBlock = Loop/Branch/Flip/ToLoopCond(...)). + /// + private static bool HasNextBlockAssignment(string code) + { + // Match: NextBlock = ...; (covers both string assignments and flow control) + // Using simple string search since the code is already well-formed C# + return code.Contains("NextBlock = "); + } + + /// + /// Formats a literal value for C# source code emission. + /// + private static string FormatLiteral(string type, object value) + { + return type switch + { + "string" => $"\"{value}\"", + "char" => $"'{value}'", + _ => value?.ToString() ?? "null" + }; + } + + /// + /// Preprocesses an entire block of code to rewrite dotted plugin calls + /// into PluginCall() invocations. Uses the same Roslyn SyntaxRewriter + /// as the per-statement version but operates on the whole block at once. + /// + private static string PreProcessPluginCalls(string code) + { + try + { + var syntaxTree = CSharpSyntaxTree.ParseText(code); + var root = syntaxTree.GetCompilationUnitRoot(); + var rewriter = new BlockScriptExecutor.PluginCallRewriter(); + var rewritten = rewriter.Visit(root); + + var result = rewritten.ToString(); + if (result != code) + { + Log.Debug("[BlockCompiler] PreProcessPluginCalls: rewrote plugin calls in block code"); + } + return result; + } + catch (System.Exception ex) + { + Log.Warning(ex, "[BlockCompiler] PreProcessPluginCalls: exception, returning original code"); + return code; + } + } +} \ No newline at end of file diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutor.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutor.cs index a95962b2..f3e5cc92 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutor.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutor.cs @@ -22,6 +22,16 @@ namespace KitX.Core.Workflow.BlockScripting; /// public class BlockScriptExecutor : IBlockScriptExecutor { + /// + /// Shared ScriptOptions with Microsoft.CSharp reference for dynamic support + /// and core imports for CSharpScript evaluation. + /// + private static readonly ScriptOptions ScriptOptions = ScriptOptions.Default + .WithReferences( + typeof(BlockScriptExecutionGlobals).Assembly, + typeof(Microsoft.CSharp.RuntimeBinder.Binder).Assembly) + .WithImports("System", "KitX.Core.Workflow.BlockScripting", "KitX.Core.Workflow"); + private readonly BlockScopeManager _scopeManager; private readonly Stopwatch _stopwatch = new(); private ScriptState? _scriptState; @@ -30,6 +40,11 @@ public class BlockScriptExecutor : IBlockScriptExecutor private BlockScriptExecutionGlobals? _globals; private IPluginManager? _pluginManager; + // Block-level precompilation + private readonly BlockCompiler _blockCompiler = new(); + private Dictionary? _blockCodeCache; + private HashSet? _predeclaredVariables; + /// /// Creates a new block script executor /// @@ -84,6 +99,14 @@ public async Task ExecuteAsync( _output.Clear(); // Clear the executor's output list _scriptState = null; + // Clear block compilation cache for new execution + _blockCodeCache = new Dictionary(); + + // Collect predeclared variable names (declared in initialization script) + // These variables are already declared as dynamic/var in the init script, + // so blocks should only emit assignments, not re-declarations + _predeclaredVariables = CollectPredeclaredVariables(script); + // Reset run-level state (e.g. Flip counter) for fresh execution from Entry _globals?.ResetRunState(); @@ -385,12 +408,128 @@ public BlockScriptValidationResult Validate(BlockScript script) } /// - /// Executes a single block and returns execution result for state machine - /// New design: NextBlock is set by Loop/Branch expressions, checked after each statement + /// Executes a single block and returns execution result for state machine. + /// Uses block-level precompilation for MainBlock/NamedBlock (one ContinueWithAsync per block), + /// and statement-by-statement execution for ConstBlock/PubVarBlock (special DefaultValue handling). /// private async Task ExecuteBlockAsync( BlockDefinition block, CancellationToken cancellationToken) + { + // ConstBlock and PubVarBlock: keep statement-by-statement execution + // (they only run once and have special DefaultValue handling) + if (block.Type == BlockType.ConstBlock || block.Type == BlockType.PubVarBlock) + return await ExecuteBlockAsyncStatementByStatement(block, cancellationToken); + + // MainBlock/NamedBlock/LoopBlock: use block-level precompilation + return await ExecuteBlockAsyncPrecompiled(block, cancellationToken); + } + + /// + /// Executes a block using precompiled code (single ContinueWithAsync call for the entire block). + /// This is the fast path that eliminates per-statement compilation overhead. + /// + private async Task ExecuteBlockAsyncPrecompiled( + BlockDefinition block, + CancellationToken cancellationToken) + { + _globals?.ResetNextBlock(); + + var blockCode = GetOrCompileBlockCode(block); + if (string.IsNullOrWhiteSpace(blockCode)) + { + // Empty block with no NextBlock — just continue + return BlockExecutionResult.ContinueTo(block.NextBlockName); + } + + try + { + await EvaluateBlockCodeAsync(blockCode, cancellationToken); + } + catch (Exception ex) + { + Log.Warning(ex, "[BlockScriptExecutor] Precompiled block '{BlockName}' failed, falling back to statement-by-statement", + block.Name); + // Fallback to statement-by-statement execution on precompilation failure + _globals?.ResetNextBlock(); + return await ExecuteBlockAsyncStatementByStatement(block, cancellationToken); + } + + // NextBlock was set by block code (NextBlock=xxx, Branch, Loop, ToLoopCond, Flip) + if (!string.IsNullOrEmpty(_globals?.NextBlock)) + return BlockExecutionResult.ContinueTo(_globals.NextBlock); + + // Fallback: if no NextBlock was set, use BlockDefinition.NextBlockName + return BlockExecutionResult.ContinueTo(block.NextBlockName); + } + + /// + /// Evaluates precompiled block code (multi-statement) via ContinueWithAsync. + /// Unlike EvaluateExpressionAsync, this does NOT wrap code or apply PreProcessPluginCalls + /// (those are already handled by BlockCompiler during compilation). + /// + private async Task EvaluateBlockCodeAsync(string blockCode, CancellationToken cancellationToken) + { + if (_scriptState == null) + { + // Should not happen — session is initialized before block execution + _globals ??= new BlockScriptExecutionGlobals(_scopeManager, _output, _pluginManager); + _scriptState = await CSharpScript.RunAsync( + blockCode, ScriptOptions, globals: _globals, cancellationToken: cancellationToken); + return _scriptState.ReturnValue; + } + + _scriptState = await _scriptState.ContinueWithAsync( + blockCode, ScriptOptions, cancellationToken: cancellationToken); + return _scriptState.ReturnValue; + } + + /// + /// Gets compiled block code from cache, or compiles it on first access. + /// + private string? GetOrCompileBlockCode(BlockDefinition block) + { + if (_blockCodeCache == null) + _blockCodeCache = new Dictionary(); + + if (!_blockCodeCache.TryGetValue(block.Name, out var code)) + { + code = _blockCompiler.CompileBlock(block, _predeclaredVariables ?? new HashSet()) ?? string.Empty; + _blockCodeCache[block.Name] = code; + Log.Debug("[BlockScriptExecutor] Compiled block '{BlockName}' ({StatementCount} statements) → {CodeLength} chars", + block.Name, block.Statements.Count, code.Length); + } + return code; + } + + /// + /// Collects variable names declared in the initialization script (ConstBlock + PubVarBlock). + /// These variables are predeclared, so blocks should only emit assignments, not re-declarations. + /// + private static HashSet CollectPredeclaredVariables(BlockScript script) + { + var variables = new HashSet(); + if (script.ConstBlock != null) + { + foreach (var v in script.ConstBlock.Variables) + variables.Add(v.Name); + } + if (script.PubVarBlock != null) + { + foreach (var v in script.PubVarBlock.Variables) + variables.Add(v.Name); + } + return variables; + } + + /// + /// Executes a single block statement-by-statement (legacy path). + /// Used for ConstBlock/PubVarBlock (special DefaultValue handling) + /// and as fallback when precompilation fails. + /// + private async Task ExecuteBlockAsyncStatementByStatement( + BlockDefinition block, + CancellationToken cancellationToken) { // Reset NextBlock at the start of each block _globals?.ResetNextBlock(); @@ -538,13 +677,7 @@ private async Task ExecuteExpressionAsync( // Variables and helpers declared in previous evaluations are preserved _scriptState = await _scriptState.ContinueWithAsync( fullCode, - ScriptOptions.Default - .WithReferences(typeof(BlockScriptExecutionGlobals).Assembly) - .WithImports( - "System", - "KitX.Core.Workflow.BlockScripting", - "KitX.Core.Workflow" - ), + ScriptOptions, cancellationToken: cancellationToken); result = _scriptState.ReturnValue; } @@ -555,13 +688,7 @@ private async Task ExecuteExpressionAsync( var globals = _globals; _scriptState = await CSharpScript.RunAsync( fullCode, - ScriptOptions.Default - .WithReferences(typeof(BlockScriptExecutionGlobals).Assembly) - .WithImports( - "System", - "KitX.Core.Workflow.BlockScripting", - "KitX.Core.Workflow" - ), + ScriptOptions, globals: globals, cancellationToken: cancellationToken); result = _scriptState.ReturnValue; @@ -639,7 +766,7 @@ private static string PreProcessPluginCalls(string expression) /// (e.g. TestPlugin.WPF.Core.HelloKitX()) into PluginCall("...", "...", args) calls. /// Operates at the AST level, preserving all expression structure and handling nesting. /// - private class PluginCallRewriter : CSharpSyntaxRewriter + internal class PluginCallRewriter : CSharpSyntaxRewriter { public override SyntaxNode VisitInvocationExpression(InvocationExpressionSyntax node) { @@ -731,7 +858,18 @@ private string BuildInitializationScript(BlockScript script) { if (variable.DefaultValue != null) { - initCode.AppendLine($"var {variable.Name} = {variable.DefaultValue};"); + // Prefer InitialValueExpression (preserves original C# source with + // proper quoting/escaping) over DefaultValue (raw .NET object). + // For string/char types, DefaultValue lacks quotes or escape sequences. + var initExpr = !string.IsNullOrEmpty(variable.InitialValueExpression) + ? variable.InitialValueExpression + : variable.Type switch + { + "string" => $"\"{variable.DefaultValue}\"", + "char" => $"'{variable.DefaultValue}'", + _ => variable.DefaultValue.ToString()! + }; + initCode.AppendLine($"var {variable.Name} = {initExpr};"); } else { @@ -747,7 +885,17 @@ private string BuildInitializationScript(BlockScript script) { if (variable.DefaultValue != null) { - initCode.AppendLine($"var {variable.Name} = {variable.DefaultValue};"); + // Prefer InitialValueExpression (preserves original C# source with + // proper quoting/escaping) over DefaultValue (raw .NET object). + var initExpr = !string.IsNullOrEmpty(variable.InitialValueExpression) + ? variable.InitialValueExpression + : variable.Type switch + { + "string" => $"\"{variable.DefaultValue}\"", + "char" => $"'{variable.DefaultValue}'", + _ => variable.DefaultValue.ToString()! + }; + initCode.AppendLine($"var {variable.Name} = {initExpr};"); } else { @@ -774,9 +922,7 @@ private async Task InitializeScriptSessionAsync(CancellationToken cancellationTo _globals = new BlockScriptExecutionGlobals(_scopeManager, _output, _pluginManager); _scriptState = await CSharpScript.RunAsync( "0", // Simple expression to establish session - ScriptOptions.Default - .WithReferences(typeof(BlockScriptExecutionGlobals).Assembly) - .WithImports("System", "KitX.Core.Workflow.BlockScripting", "KitX.Core.Workflow"), + ScriptOptions, globals: _globals, cancellationToken: cancellationToken); return; @@ -789,9 +935,7 @@ private async Task InitializeScriptSessionAsync(CancellationToken cancellationTo _globals = new BlockScriptExecutionGlobals(_scopeManager, _output, _pluginManager); _scriptState = await CSharpScript.RunAsync( initCode, - ScriptOptions.Default - .WithReferences(typeof(BlockScriptExecutionGlobals).Assembly) - .WithImports("System", "KitX.Core.Workflow.BlockScripting", "KitX.Core.Workflow"), + ScriptOptions, globals: _globals, cancellationToken: cancellationToken); } @@ -802,9 +946,7 @@ private async Task InitializeScriptSessionAsync(CancellationToken cancellationTo _globals = new BlockScriptExecutionGlobals(_scopeManager, _output, _pluginManager); _scriptState = await CSharpScript.RunAsync( "0", - ScriptOptions.Default - .WithReferences(typeof(BlockScriptExecutionGlobals).Assembly) - .WithImports("System", "KitX.Core.Workflow.BlockScripting", "KitX.Core.Workflow"), + ScriptOptions, globals: _globals, cancellationToken: cancellationToken); } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockStatementExtractor.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockStatementExtractor.cs index 8ad29351..a86c3302 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockStatementExtractor.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockStatementExtractor.cs @@ -232,13 +232,24 @@ private void ExtractStatements(SyntaxNode root, BlockDefinition block, BlockType } else { - Log.Debug("[BlockStatementExtractor] assignment.Right is NOT InvocationExpressionSyntax, type = {Type}", assignment.Right.GetType().Name); - block.Statements.Add(new ExpressionStatement + // Check for NextBlock = "BlockName" (plain string assignment to NextBlock) + if (assignment.Left is IdentifierNameSyntax { Identifier.Text: "NextBlock" } + && assignment.Right is LiteralExpressionSyntax nextBlockLiteral + && nextBlockLiteral.Token.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.StringLiteralToken)) { - LineNumber = exprStmt.GetLineNumber(), - SourceCode = exprText, - Expression = exprText - }); + block.NextBlockName = nextBlockLiteral.Token.ValueText; + Log.Debug("[BlockStatementExtractor] Set NextBlockName = {NextBlockName}", block.NextBlockName); + } + else + { + Log.Debug("[BlockStatementExtractor] assignment.Right is NOT InvocationExpressionSyntax, type = {Type}", assignment.Right.GetType().Name); + block.Statements.Add(new ExpressionStatement + { + LineNumber = exprStmt.GetLineNumber(), + SourceCode = exprText, + Expression = exprText + }); + } } } else diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintToBlockScriptConverter.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintToBlockScriptConverter.cs index fbbd8997..efd9c189 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintToBlockScriptConverter.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintToBlockScriptConverter.cs @@ -1,26 +1,26 @@ using System.Collections.Generic; using System.Linq; using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.Blueprint.ReversePipeline; +using KitX.Core.Workflow.Blueprint.CFG; using Serilog; namespace KitX.Core.Workflow.Blueprint; /// /// Converts Blueprint back to a fully-expanded BlockScript source code. -/// Thin orchestrator that delegates to pipeline phases: -/// Phase 1: BlueprintAnalyzer — data index construction -/// Phase 2: ExecutionFlowWalker — walk execution flow (BlockScopes or topology path) -/// Phase 3: ConditionDuplicator — loop condition duplication (topology path only) -/// Phase 4: BlockScriptAssembler — source code assembly +/// Thin orchestrator that delegates to CFG pipeline phases: +/// CFGBuilderFromBlueprint → CFGConditionDuplicator → ScriptGenerator → ScriptSerializer /// public class BlueprintToBlockScriptConverter : IBlueprintToBlockScriptConverter { - private readonly BlueprintAnalyzer _analyzer = new(); - private readonly ConditionDuplicator _conditionDuplicator = new(); - private readonly BlockScriptAssembler _assembler = new(); private readonly NodeExportHelper _exportHelper = new(); + // CFG pipeline components + private readonly CFGBuilderFromBlueprint _cfgBuilder; + private readonly CFGConditionDuplicator _cfgConditionDuplicator = new(); + private readonly ScriptGenerator _scriptGenerator = new(); + private readonly ScriptSerializer _scriptSerializer = new(); + public BlueprintToBlockScriptConverter(IEnumerable strategies) { var strategyMap = new Dictionary(); @@ -41,11 +41,11 @@ public BlueprintToBlockScriptConverter(IEnumerable strategi } } - Walker = new ExecutionFlowWalker(strategyMap, builtinMap, _exportHelper); + _cfgBuilder = new CFGBuilderFromBlueprint(strategyMap, builtinMap, _exportHelper); } - /// Execution flow walker — accessible for testing - internal ExecutionFlowWalker Walker { get; } + /// Last CFG built, for diagnostics + internal ControlFlowGraph? LastCFG { get; private set; } // ────────────────────────────────────────────── // Public API @@ -60,50 +60,38 @@ public string Convert(Contract.Workflow.Blueprint blueprint) /// public BlockScript ConvertToBlockScript(Contract.Workflow.Blueprint blueprint) - { - if (blueprint.BlockScopes.Count > 0) - return ConvertWithBlockScopes(blueprint); - return ConvertWithTopology(blueprint); - } - - // ────────────────────────────────────────────── - // Conversion paths - // ────────────────────────────────────────────── - - private BlockScript ConvertWithBlockScopes(Contract.Workflow.Blueprint blueprint) { Blueprint = blueprint; - var ctx = new ReverseConversionContext { Blueprint = blueprint, Script = new BlockScript() }; + + // Set up context for NodeExportHelper and CFG builder (needed for statement generation) + var ctx = new ConversionContext { Blueprint = blueprint, Script = new BlockScript() }; _exportHelper.SetContext(blueprint, ctx); + _cfgBuilder.SetContext(blueprint, ctx); - // Phase 1 - _analyzer.Analyze(ctx); + // Phase 1: Build CFG from Blueprint (unified algorithm) + var cfg = _cfgBuilder.Build(blueprint); + LastCFG = cfg; - // Phase 2: Generate statements from stored block membership - Walker.WalkFromBlockScopes(blueprint, ctx); + Log.Debug("[BlueprintToScript] CFG: {BlockCount} blocks, {EdgeCount} edges", + cfg.Blocks.Count, cfg.Blocks.Sum(b => b.Successors.Count)); - // Phase 4 - _assembler.Assemble(ctx); - return ctx.Script; - } + // Phase 2: Duplicate loop conditions + _cfgConditionDuplicator.Duplicate(cfg); - private BlockScript ConvertWithTopology(Contract.Workflow.Blueprint blueprint) - { - Blueprint = blueprint; - var ctx = new ReverseConversionContext { Blueprint = blueprint, Script = new BlockScript() }; - _exportHelper.SetContext(blueprint, ctx); + // Phase 3: Generate BlockScript from CFG + var script = _scriptGenerator.Generate(cfg); - // Phase 1 - _analyzer.Analyze(ctx); + // Phase 4: Serialize to source code + script.SourceCode = _scriptSerializer.Serialize(script); - // Phase 2: Walk execution flow - Walker.WalkExecutionFlow(ctx); + // Transfer helper functions from Blueprint to BlockScript + // (they were stored in Blueprint during BS→BP forward conversion but were + // previously lost in the BP→BS reverse conversion, causing UnknownMethodName) + script.HelperFunctions = blueprint.HelperFunctions ?? []; - // Phase 3: Duplicate loop conditions - _conditionDuplicator.Duplicate(ctx); + Log.Debug("[BlueprintToScript] Done. Source code length: {Len}, HelperFunctions: {Count}", + script.SourceCode?.Length ?? 0, script.HelperFunctions?.Count ?? 0); - // Phase 4: Assemble source code - _assembler.Assemble(ctx); - return ctx.Script; + return script; } -} +} \ No newline at end of file diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGBlock.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGBlock.cs new file mode 100644 index 00000000..46df6919 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGBlock.cs @@ -0,0 +1,82 @@ +namespace KitX.Core.Workflow.Blueprint.CFG; + +/// +/// Type of a CFG block, indicating its structural role in the control flow. +/// +internal enum CFGBlockType +{ + /// MainBlock — the entry point of the script. + Entry, + + /// A plain sequential block with no control flow divergence at the end. + Basic, + + /// A block ending with a Branch statement (condition → true/false targets). + BranchHeader, + + /// A block ending with a Loop statement (condition → body/exit targets). + LoopHeader, + + /// The body of a loop — may reach back to the LoopHeader via ToLoopCond. + LoopBody, + + /// The exit block of a loop (what runs after the loop condition is false). + LoopExit, +} + +/// +/// A basic block in the Control Flow Graph. Contains a maximal sequence of +/// statements with a single entry point and a single exit point (or +/// control flow divergence at the end). +/// +internal class CFGBlock +{ + /// + /// Block name — stable across round-trips. For MainBlock, this is "#MainBlock". + /// For named blocks, this is the user-defined name (e.g., "LoopCond", "HandleIncPointer"). + /// + public string Name { get; set; } = string.Empty; + + /// + /// The structural type of this block. + /// + public CFGBlockType Type { get; set; } = CFGBlockType.Basic; + + /// + /// Ordered statements within this block. All statements are flat — + /// no nested calls, all expressions are either simple or PubVar assignments. + /// + public List Statements { get; set; } = []; + + /// + /// Typed edges from this block to its successors. + /// A block can have multiple successors (e.g., BranchHeader → True/False). + /// + public List Successors { get; set; } = []; + + /// + /// For sequential fall-through (NextBlock = "..."), the name of the next block. + /// Null if this block ends with a control flow statement (Branch/Loop/ToLoopCond/Break) + /// or has no successor. + /// + public string? NextBlockName { get; set; } + + /// + /// For loop body blocks, the name of the parent loop header block. + /// Used to generate correct ToLoopCond arguments. + /// + public string? ParentLoopBlockName { get; set; } + + /// + /// Whether this block is the MainBlock (entry point). + /// + public bool IsMainBlock => Type == CFGBlockType.Entry; + + /// + /// Whether this block ends with a control flow statement + /// (Branch, Loop, ToLoopCond, Break, Return). + /// + public bool EndsWithControlFlow => + Statements.Count > 0 && Statements[^1] is { Kind: CFGStatementKind.Branch or CFGStatementKind.Loop + or CFGStatementKind.ToLoopCond or CFGStatementKind.Break }; +} \ No newline at end of file diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGBuilderFromBlueprint.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGBuilderFromBlueprint.cs new file mode 100644 index 00000000..b64784af --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGBuilderFromBlueprint.cs @@ -0,0 +1,1161 @@ +using System.Collections.Generic; +using System.Linq; +using KitX.Core.Contract.Workflow; +using KitX.Core.Workflow.BlockScripting; +using Serilog; + +using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Pins; +using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Blocks; + +namespace KitX.Core.Workflow.Blueprint.CFG; + +/// +/// Builds a from a . +/// This is the unified BP→BS algorithm. +/// +/// Whether is present or not, +/// this single algorithm produces a CFG. When BlockScopes are available, they guide +/// block membership; when absent, blocks are derived from topology alone. +/// +internal class CFGBuilderFromBlueprint +{ + private readonly Dictionary _strategies; + private readonly Dictionary _builtinFunctionStrategies; + private readonly NodeExportHelper _exportHelper; + + public CFGBuilderFromBlueprint( + Dictionary strategies, + Dictionary builtinFunctionStrategies, + NodeExportHelper exportHelper) + { + _strategies = strategies; + _builtinFunctionStrategies = builtinFunctionStrategies; + _exportHelper = exportHelper; + } + + /// + /// Builds a ControlFlowGraph from a Blueprint. + /// Single entry point that unifies the BlockScopes-based and topology-based paths. + /// + public ControlFlowGraph Build(Contract.Workflow.Blueprint blueprint) + { + var cfg = new ControlFlowGraph(); + + // ── Step 1: Index and classify all nodes and connections ── + var nodeById = new Dictionary(); + var execConns = new List(); + var dataConns = new List(); + var inputDataMap = new Dictionary<(string, string), DataEdgeInfo>(); + var consumedOutputs = new HashSet<(string, string)>(); + var allPubVars = new List(); + var autoPubVars = new List(); + int pubVarCounter = 1; + + AnalyzeConnections(blueprint, nodeById, execConns, dataConns, inputDataMap, + consumedOutputs, allPubVars, autoPubVars, ref pubVarCounter); + + // Populate ConversionContext with analysis results for statement generation + if (_currentCtx != null) + { + _currentCtx.NodeById = nodeById; + _currentCtx.ExecConnections = execConns; + _currentCtx.DataConnections = dataConns; + _currentCtx.InputDataMap = inputDataMap; + _currentCtx.ConsumedOutputs = consumedOutputs; + _currentCtx.AllPubVars = allPubVars; + _currentCtx.AutoPubVars = autoPubVars; + _currentCtx.PubVarCounter = pubVarCounter; + } + + // ── Step 2: Compute reachability from Entry ── + var reachableNodeIds = FindReachableNodeIds(blueprint, nodeById, execConns); + + // ── Step 3: Build CFG blocks ── + if (blueprint.BlockScopes.Count > 0) + BuildBlocksFromScopes(blueprint, cfg, nodeById, reachableNodeIds, execConns); + else + BuildBlocksFromTopology(blueprint, cfg, nodeById, reachableNodeIds, execConns); + + // ── Step 3.5: Resolve Branch/Loop target block names ── + // Strategy-generated statements don't know their target blocks; we resolve them + // from Blueprint connections and the node→block mapping. + ResolveControlFlowTargets(cfg, blueprint, nodeById, execConns); + + // ── Step 4: Build CFG edges ── + BuildEdgesFromTopology(cfg, blueprint, nodeById, execConns); + + // ── Step 5: Set entry block ── + cfg.EntryBlock = cfg.Blocks.FirstOrDefault(b => b.IsMainBlock) ?? cfg.Blocks.FirstOrDefault(); + + // ── Step 6: Set block types ── + ClassifyBlockTypes(cfg); + + // ── Step 7: Set parent loop references ── + SetParentLoopReferences(cfg); + + // ── Step 8: Transfer PubVar data ── + cfg.PubVarDeclarations = allPubVars; + cfg.PubVarCounter = pubVarCounter; + + // ── Step 9: Transfer const declarations ── + // Collect ConstNode (initialized variables) and VariableNode (uninitialized variables) + foreach (var node in blueprint.Nodes.OfType()) + { + cfg.ConstDeclarations.Add(new ConstDeclaration + { + Name = node.ConstName, + Type = node.ConstType, + DefaultValue = node.ConstValue, + InitialValueExpression = null // Blueprint doesn't store source expression + }); + } + foreach (var node in blueprint.Nodes.OfType()) + { + cfg.ConstDeclarations.Add(new ConstDeclaration + { + Name = node.VarName, + Type = node.VarType, + DefaultValue = null, + InitialValueExpression = null + }); + } + + Log.Debug("[CFGBuilderFromBlueprint] Built CFG: {BlockCount} blocks, {EdgeCount} edges", + cfg.Blocks.Count, cfg.Blocks.Sum(b => b.Successors.Count)); + + return cfg; + } + + // ════════════════════════════════════════════════════════════════════ + // Step 1: Connection Analysis + // ════════════════════════════════════════════════════════════════════ + + private static void AnalyzeConnections( + Contract.Workflow.Blueprint blueprint, + Dictionary nodeById, + List execConns, + List dataConns, + Dictionary<(string, string), DataEdgeInfo> inputDataMap, + HashSet<(string, string)> consumedOutputs, + List allPubVars, + List autoPubVars, + ref int pubVarCounter) + { + foreach (var node in blueprint.Nodes) + nodeById[node.Id] = node; + + foreach (var conn in blueprint.Connections) + { + var sourceNode = blueprint.GetNodeById(conn.SourceNodeId); + if (sourceNode == null) continue; + + var sourcePin = sourceNode.GetPinById(conn.SourcePinId); + if (sourcePin == null) continue; + + if (sourcePin.Type == PinType.Execution) + { + execConns.Add(conn); + } + else + { + dataConns.Add(conn); + + var targetNode = blueprint.GetNodeById(conn.TargetNodeId); + if (targetNode != null) + { + var targetPin = targetNode.GetPinById(conn.TargetPinId); + if (targetPin != null) + { + inputDataMap[(conn.TargetNodeId, targetPin.Name)] = new DataEdgeInfo + { + SourceNode = sourceNode, + SourcePinName = sourcePin.Name, + SourcePin = sourcePin, + PubVarName = conn.PubVarName, + Connection = conn + }; + + consumedOutputs.Add((conn.SourceNodeId, sourcePin.Name)); + } + } + } + } + + // Auto-assign PubVar names where needed + foreach (var conn in dataConns) + { + if (!string.IsNullOrEmpty(conn.PubVarName)) continue; + + var sourceNode = blueprint.GetNodeById(conn.SourceNodeId); + if (sourceNode == null) continue; + if (sourceNode.NodeType == BlueprintNodeType.Const) continue; + + if (sourceNode.NodeType is BlueprintNodeType.Get or BlueprintNodeType.Call + or BlueprintNodeType.CallHelper + || (sourceNode is BuiltinFunctionNode bfn && bfn.FunctionName == "Get")) + { + var sourcePin = sourceNode.GetPinById(conn.SourcePinId); + if (sourcePin == null) continue; + + var pubVar = Pipeline.ExprUtils.GeneratePubVarName(pubVarCounter++); + conn.PubVarName = pubVar; + autoPubVars.Add(pubVar); + + var targetNode = blueprint.GetNodeById(conn.TargetNodeId); + if (targetNode != null) + { + var targetPin = targetNode.GetPinById(conn.TargetPinId); + if (targetPin != null && inputDataMap.TryGetValue( + (conn.TargetNodeId, targetPin.Name), out var info)) + { + info.PubVarName = pubVar; + } + } + } + } + + foreach (var conn in dataConns) + { + if (!string.IsNullOrEmpty(conn.PubVarName) && !allPubVars.Contains(conn.PubVarName)) + allPubVars.Add(conn.PubVarName); + } + } + + // ════════════════════════════════════════════════════════════════════ + // Step 2: Reachability Analysis + // ════════════════════════════════════════════════════════════════════ + + private static HashSet FindReachableNodeIds( + Contract.Workflow.Blueprint blueprint, + Dictionary nodeById, + List execConns) + { + var reachable = new HashSet(); + var entry = blueprint.Nodes.FirstOrDefault(n => + n.NodeType == BlueprintNodeType.Entry || n.NodeType == BlueprintNodeType.PluginTrigger); + if (entry == null) return reachable; + + var queue = new Queue(); + queue.Enqueue(entry); + reachable.Add(entry.Id); + + while (queue.Count > 0) + { + var current = queue.Dequeue(); + foreach (var pin in current.OutputPins) + { + if (pin.Type != PinType.Execution) continue; + var conn = execConns.FirstOrDefault(c => c.SourcePinId == pin.Id); + if (conn == null) continue; + var target = blueprint.GetNodeById(conn.TargetNodeId); + if (target == null || reachable.Contains(target.Id)) continue; + reachable.Add(target.Id); + queue.Enqueue(target); + } + } + + return reachable; + } + + // ════════════════════════════════════════════════════════════════════ + // Step 3a: Build Blocks from BlockScopes + // ════════════════════════════════════════════════════════════════════ + + private void BuildBlocksFromScopes( + Contract.Workflow.Blueprint blueprint, + ControlFlowGraph cfg, + Dictionary nodeById, + HashSet reachableNodeIds, + List execConns) + { + foreach (var scope in blueprint.BlockScopes) + { + var block = new CFGBlock + { + Name = scope.Name, + Type = scope.IsMainBlock ? CFGBlockType.Entry : CFGBlockType.Basic, + NextBlockName = scope.NextBlockName, + }; + + foreach (var nodeId in scope.NodeIds) + { + if (!reachableNodeIds.Contains(nodeId)) continue; + if (!nodeById.TryGetValue(nodeId, out var node)) continue; + + var stmt = GenerateStatement(node, nodeById, execConns, blueprint); + if (stmt != null) + block.Statements.Add(stmt); + } + + cfg.Blocks.Add(block); + } + } + + // ════════════════════════════════════════════════════════════════════ + // Step 3b: Build Blocks from Topology (no BlockScopes) + // ════════════════════════════════════════════════════════════════════ + + private void BuildBlocksFromTopology( + Contract.Workflow.Blueprint blueprint, + ControlFlowGraph cfg, + Dictionary nodeById, + HashSet reachableNodeIds, + List execConns) + { + var entryNode = blueprint.Nodes.FirstOrDefault(n => + n.NodeType == BlueprintNodeType.Entry || n.NodeType == BlueprintNodeType.PluginTrigger); + if (entryNode == null) return; + + // DFS from Entry, splitting at Branch/Loop nodes + var mainBlock = new CFGBlock { Name = MainBlock, Type = CFGBlockType.Entry }; + cfg.Blocks.Add(mainBlock); + + var visited = new HashSet(); + var pendingControlFlowNodes = new List(); + var loopNodes = new Dictionary(); + var loopOwnerBlockNames = new Dictionary(); + var blockCounter = 0; + + WalkNode(entryNode, mainBlock, cfg, nodeById, reachableNodeIds, execConns, + blueprint, visited, pendingControlFlowNodes, loopNodes, + loopOwnerBlockNames, ref blockCounter, loopbackTargetId: null); + + // Process sub-graphs (Branch/Loop bodies) + ProcessSubGraphs(cfg, nodeById, reachableNodeIds, execConns, blueprint, + pendingControlFlowNodes, loopNodes, loopOwnerBlockNames, ref blockCounter); + } + + private void WalkNode( + BlueprintNode node, CFGBlock currentBlock, ControlFlowGraph cfg, + Dictionary nodeById, HashSet reachableNodeIds, + List execConns, Contract.Workflow.Blueprint blueprint, + HashSet visited, List pendingControlFlowNodes, + Dictionary loopNodes, + Dictionary loopOwnerBlockNames, + ref int blockCounter, string? loopbackTargetId) + { + if (visited.Contains(node.Id)) return; + visited.Add(node.Id); + + if (loopbackTargetId != null && node.Id == loopbackTargetId) + { + var ownerName = loopOwnerBlockNames.TryGetValue(loopbackTargetId, out var n) ? n : null; + currentBlock.Statements.Add(CreateToLoopCondStatement(ownerName)); + currentBlock.Successors.Add(new CFGEdge + { + FromBlockName = currentBlock.Name, + ToBlockName = ownerName ?? loopbackTargetId, + Type = CFGEdgeType.LoopbackToCondition + }); + return; + } + + var stmt = GenerateStatement(node, nodeById, execConns, blueprint); + if (stmt != null) + { + currentBlock.Statements.Add(stmt); + + if (stmt.Kind == CFGStatementKind.Branch || stmt.Kind == CFGStatementKind.Loop) + { + pendingControlFlowNodes.Add(node); + if (stmt.Kind == CFGStatementKind.Loop) + { + loopNodes[node.Id] = node; + loopOwnerBlockNames[node.Id] = currentBlock.Name; + } + } + } + + // If ToLoopCond, don't follow exec chain + if (stmt is { Kind: CFGStatementKind.ToLoopCond }) + return; + + if (IsBranchNode(node) || IsLoopNode(node)) + { + pendingControlFlowNodes.Add(node); + return; + } + + // Follow execution chain + var execOut = node.OutputPins.FirstOrDefault(p => p.Name == Exec); + if (execOut == null) return; + + var execConn = execConns.FirstOrDefault(c => c.SourcePinId == execOut.Id); + if (execConn == null) return; + + var nextNode = blueprint.GetNodeById(execConn.TargetNodeId); + if (nextNode == null) return; + + if (loopbackTargetId != null && nextNode.Id == loopbackTargetId) + { + var ownerName2 = loopOwnerBlockNames.TryGetValue(loopbackTargetId, out var n2) ? n2 : null; + currentBlock.Statements.Add(CreateToLoopCondStatement(ownerName2)); + currentBlock.Successors.Add(new CFGEdge + { + FromBlockName = currentBlock.Name, + ToBlockName = ownerName2 ?? loopbackTargetId, + Type = CFGEdgeType.LoopbackToCondition + }); + return; + } + + WalkNode(nextNode, currentBlock, cfg, nodeById, reachableNodeIds, execConns, + blueprint, visited, pendingControlFlowNodes, loopNodes, + loopOwnerBlockNames, ref blockCounter, loopbackTargetId); + } + + private void ProcessSubGraphs( + ControlFlowGraph cfg, Dictionary nodeById, + HashSet reachableNodeIds, List execConns, + Contract.Workflow.Blueprint blueprint, + List pendingControlFlowNodes, + Dictionary loopNodes, + Dictionary loopOwnerBlockNames, + ref int blockCounter) + { + var processedNodes = new HashSet(); + + while (pendingControlFlowNodes.Count > 0) + { + var pendingList = pendingControlFlowNodes.ToList(); + pendingControlFlowNodes.Clear(); + + foreach (var node in pendingList) + { + if (processedNodes.Contains(node.Id)) continue; + processedNodes.Add(node.Id); + + if (IsBranchNode(node)) + ProcessBranchSubGraph(node, cfg, nodeById, reachableNodeIds, execConns, + blueprint, loopNodes, loopOwnerBlockNames, ref blockCounter); + else if (IsLoopNode(node)) + ProcessLoopSubGraph(node, cfg, nodeById, reachableNodeIds, execConns, + blueprint, loopNodes, loopOwnerBlockNames, ref blockCounter); + } + } + } + + private void ProcessBranchSubGraph( + BlueprintNode branchNode, ControlFlowGraph cfg, + Dictionary nodeById, HashSet reachableNodeIds, + List execConns, Contract.Workflow.Blueprint blueprint, + Dictionary loopNodes, + Dictionary loopOwnerBlockNames, ref int blockCounter) + { + foreach (var pinName in new[] { True, False }) + { + var pin = branchNode.OutputPins.FirstOrDefault(p => p.Name == pinName); + if (pin == null) continue; + + var conn = execConns.FirstOrDefault(c => c.SourcePinId == pin.Id); + if (conn == null) continue; + + var targetNode = blueprint.GetNodeById(conn.TargetNodeId); + if (targetNode == null) continue; + + var blockName = $"Block_{blockCounter++}"; + var block = new CFGBlock { Name = blockName, Type = CFGBlockType.Basic }; + cfg.Blocks.Add(block); + + WalkNode(targetNode, block, cfg, nodeById, reachableNodeIds, execConns, + blueprint, new HashSet(), pendingControlFlowNodes: new(), + loopNodes, loopOwnerBlockNames, ref blockCounter, loopbackTargetId: null); + + // Update the Branch statement in the parent block + var branchStmt = FindBranchStatement(cfg, branchNode.Id); + if (branchStmt != null) + { + if (pinName == True) branchStmt.TrueBlockName = blockName; + else branchStmt.FalseBlockName = blockName; + branchStmt.OriginalExpression = RegenerateBranchSource(branchStmt); + } + } + } + + private void ProcessLoopSubGraph( + BlueprintNode loopNode, ControlFlowGraph cfg, + Dictionary nodeById, HashSet reachableNodeIds, + List execConns, Contract.Workflow.Blueprint blueprint, + Dictionary loopNodes, + Dictionary loopOwnerBlockNames, ref int blockCounter) + { + // Loop body + var loopBodyPin = loopNode.OutputPins.FirstOrDefault(p => p.Name == LoopBody); + if (loopBodyPin != null) + { + var conn = execConns.FirstOrDefault(c => c.SourcePinId == loopBodyPin.Id); + if (conn != null) + { + var targetNode = blueprint.GetNodeById(conn.TargetNodeId); + if (targetNode != null) + { + var blockName = $"Block_{blockCounter++}"; + var block = new CFGBlock + { + Name = blockName, + Type = CFGBlockType.LoopBody, + ParentLoopBlockName = FindContainingBlockName(cfg, loopNode.Id) + }; + cfg.Blocks.Add(block); + loopOwnerBlockNames[loopNode.Id] = blockName; + + WalkNode(targetNode, block, cfg, nodeById, reachableNodeIds, execConns, + blueprint, new HashSet(), pendingControlFlowNodes: new(), + loopNodes, loopOwnerBlockNames, ref blockCounter, loopbackTargetId: loopNode.Id); + } + } + } + + // Loop exit + var loopEndPin = loopNode.OutputPins.FirstOrDefault(p => p.Name == LoopEnd); + if (loopEndPin != null) + { + var conn = execConns.FirstOrDefault(c => c.SourcePinId == loopEndPin.Id); + if (conn != null) + { + var targetNode = blueprint.GetNodeById(conn.TargetNodeId); + if (targetNode != null) + { + var blockName = $"Block_{blockCounter++}"; + var block = new CFGBlock + { + Name = blockName, + Type = CFGBlockType.LoopExit + }; + cfg.Blocks.Add(block); + + WalkNode(targetNode, block, cfg, nodeById, reachableNodeIds, execConns, + blueprint, new HashSet(), pendingControlFlowNodes: new(), + loopNodes, loopOwnerBlockNames, ref blockCounter, loopbackTargetId: null); + } + } + } + } + + // ════════════════════════════════════════════════════════════════════ + // Step 4: Build CFG Edges from Blueprint Topology + // ════════════════════════════════════════════════════════════════════ + + private static void BuildEdgesFromTopology( + ControlFlowGraph cfg, Contract.Workflow.Blueprint blueprint, + Dictionary nodeById, List execConns) + { + foreach (var block in cfg.Blocks) + { + // Skip blocks that already have edges from WalkNode + if (block.Successors.Count > 0) continue; + + // Skip blocks that end with control flow (edges added from statements) + if (block.EndsWithControlFlow) continue; + + // For BlockScopes-based blocks, derive edges from the last node's exec connection + if (block.Statements.Count == 0) continue; + + var lastStmt = block.Statements[^1]; + if (lastStmt.Kind == CFGStatementKind.Branch) + { + if (!string.IsNullOrEmpty(lastStmt.TrueBlockName)) + block.Successors.Add(new CFGEdge + { + FromBlockName = block.Name, + ToBlockName = lastStmt.TrueBlockName, + Type = CFGEdgeType.BranchTrue, + PinName = True + }); + if (!string.IsNullOrEmpty(lastStmt.FalseBlockName)) + block.Successors.Add(new CFGEdge + { + FromBlockName = block.Name, + ToBlockName = lastStmt.FalseBlockName, + Type = CFGEdgeType.BranchFalse, + PinName = False + }); + continue; + } + + if (lastStmt.Kind == CFGStatementKind.Loop) + { + if (!string.IsNullOrEmpty(lastStmt.TrueBlockName)) + block.Successors.Add(new CFGEdge + { + FromBlockName = block.Name, + ToBlockName = lastStmt.TrueBlockName, + Type = CFGEdgeType.LoopBody, + PinName = LoopBody + }); + if (!string.IsNullOrEmpty(lastStmt.FalseBlockName)) + block.Successors.Add(new CFGEdge + { + FromBlockName = block.Name, + ToBlockName = lastStmt.FalseBlockName, + Type = CFGEdgeType.LoopExit, + PinName = LoopEnd + }); + continue; + } + + if (lastStmt.Kind == CFGStatementKind.ToLoopCond) + { + if (!string.IsNullOrEmpty(lastStmt.ToLoopCondReturnTo)) + block.Successors.Add(new CFGEdge + { + FromBlockName = block.Name, + ToBlockName = lastStmt.ToLoopCondReturnTo, + Type = CFGEdgeType.LoopbackToCondition + }); + continue; + } + + if (lastStmt.Kind == CFGStatementKind.Break) + { + block.Successors.Add(new CFGEdge + { + FromBlockName = block.Name, + ToBlockName = "__break__", + Type = CFGEdgeType.Break + }); + continue; + } + + // Sequential fall-through: derive NextBlockName from the block or from exec connections + if (!string.IsNullOrEmpty(block.NextBlockName)) + { + block.Successors.Add(new CFGEdge + { + FromBlockName = block.Name, + ToBlockName = block.NextBlockName, + Type = CFGEdgeType.Sequential, + PinName = Exec + }); + } + } + + // For BlockScopes-based blocks that don't have NextBlockName and don't end with control flow, + // resolve NextBlockName from execution connections + ResolveMissingNextBlockNames(cfg, blueprint, nodeById, execConns); + } + + /// + /// Resolves missing NextBlockName for blocks that don't end with control flow + /// by following exec connections from the scope's last reachable node. + /// This replaces the ad-hoc NextBlockName fallback logic. + /// + private static void ResolveMissingNextBlockNames( + ControlFlowGraph cfg, Contract.Workflow.Blueprint blueprint, + Dictionary nodeById, List execConns) + { + foreach (var block in cfg.Blocks) + { + if (!string.IsNullOrEmpty(block.NextBlockName)) continue; + if (block.EndsWithControlFlow) continue; + if (block.Successors.Count > 0) continue; // Already has edges + + // Find the scope containing this block's nodes + var scope = blueprint.BlockScopes.FirstOrDefault(s => s.Name == block.Name); + if (scope == null) continue; + + // Find the last reachable node in this scope with an exec output + string? lastNodeId = null; + for (int i = scope.NodeIds.Count - 1; i >= 0; i--) + { + if (nodeById.ContainsKey(scope.NodeIds[i])) + { + lastNodeId = scope.NodeIds[i]; + break; + } + } + if (lastNodeId == null) continue; + if (!nodeById.TryGetValue(lastNodeId, out var lastNode)) continue; + + var execOut = lastNode.OutputPins.FirstOrDefault(p => p.Name == Exec); + if (execOut == null) continue; + + var execConn = execConns.FirstOrDefault(c => c.SourcePinId == execOut.Id); + if (execConn == null) continue; + + var targetNode = blueprint.GetNodeById(execConn.TargetNodeId); + if (targetNode == null) continue; + + // Find which CFG block contains the target node + var targetBlockName = FindBlockContainingNode(cfg, targetNode.Id, blueprint); + if (targetBlockName != null) + { + block.NextBlockName = targetBlockName; + block.Successors.Add(new CFGEdge + { + FromBlockName = block.Name, + ToBlockName = targetBlockName, + Type = CFGEdgeType.Sequential, + PinName = Exec + }); + } + } + } + + // ════════════════════════════════════════════════════════════════════ + // Step 6: Block Type Classification + // ════════════════════════════════════════════════════════════════════ + + private static void ClassifyBlockTypes(ControlFlowGraph cfg) + { + foreach (var block in cfg.Blocks) + { + if (block.IsMainBlock) continue; // Already classified as Entry + + if (block.Statements.Count > 0) + { + var lastStmt = block.Statements[^1]; + block.Type = lastStmt.Kind switch + { + CFGStatementKind.Branch => CFGBlockType.BranchHeader, + CFGStatementKind.Loop => CFGBlockType.LoopHeader, + _ => block.Type + }; + } + + // Classify blocks that are targets of LoopBody edges + if (block.ParentLoopBlockName != null && block.Type == CFGBlockType.Basic) + block.Type = CFGBlockType.LoopBody; + } + } + + // ════════════════════════════════════════════════════════════════════ + // Step 7: Set Parent Loop References + // ════════════════════════════════════════════════════════════════════ + + private static void SetParentLoopReferences(ControlFlowGraph cfg) + { + // For each block with a LoopbackToCondition edge, set ParentLoopBlockName + foreach (var block in cfg.Blocks) + { + if (block.ParentLoopBlockName != null) continue; + + var toLoopCondEdge = block.Successors.FirstOrDefault(e => e.Type == CFGEdgeType.LoopbackToCondition); + if (toLoopCondEdge != null) + block.ParentLoopBlockName = toLoopCondEdge.ToBlockName; + } + + // For each block that is the target of a LoopBody edge, set ParentLoopBlockName + foreach (var block in cfg.Blocks) + { + if (block.ParentLoopBlockName != null) continue; + + foreach (var other in cfg.Blocks) + { + var loopBodyEdge = other.Successors.FirstOrDefault(e => + e.Type == CFGEdgeType.LoopBody && e.ToBlockName == block.Name); + if (loopBodyEdge != null) + { + block.ParentLoopBlockName = other.Name; + block.Type = CFGBlockType.LoopBody; + break; + } + } + } + } + + // ════════════════════════════════════════════════════════════════════ + // Statement Generation + // ════════════════════════════════════════════════════════════════════ + + private CFGStatement? GenerateStatement( + BlueprintNode node, Dictionary nodeById, + List execConns, Contract.Workflow.Blueprint blueprint) + { + // Delegate to existing strategy-based generation, then convert to CFGStatement + var blockStmt = GenerateBlockStatement(node, nodeById, execConns, blueprint); + if (blockStmt == null) return null; + + return ConvertBlockStatementToCfgStatement(blockStmt, node); + } + + /// + /// Generates a BlockStatement using the existing export strategy system. + /// Reuses the proven strategy-based statement generation logic. + /// + private BlockStatement? GenerateBlockStatement( + BlueprintNode node, Dictionary nodeById, + List execConns, Contract.Workflow.Blueprint blueprint) + { + switch (node.NodeType) + { + case BlueprintNodeType.Entry: + case BlueprintNodeType.PluginTrigger: + return null; + case BlueprintNodeType.Break: + return new FlowControlStatement + { + ControlType = FlowControlType.Break, + SourceCode = "Break();", + LineNumber = 1 + }; + case BlueprintNodeType.BuiltinFunction when IsBreakNode(node): + return new FlowControlStatement + { + ControlType = FlowControlType.Break, + SourceCode = "Break();", + LineNumber = 1 + }; + case BlueprintNodeType.Get: + return GenerateGetStatement(node); + case BlueprintNodeType.BuiltinFunction when IsGetNode(node): + return GenerateGetStatement(node); + case BlueprintNodeType.Call: + { + if (node is not CallNode call) return null; + var callArgs = _exportHelper.GetInputArgs(call); + var funcRef = string.IsNullOrEmpty(call.PluginName) + ? call.FunctionName : $"{call.PluginName}.{call.FunctionName}"; + var sourceCode = $"{funcRef}({callArgs})"; + var callStmt = new ExpressionStatement + { + Expression = sourceCode, + SourceCode = sourceCode + ";", + LineNumber = 1 + }; + PostProcessCallReturn(node, callStmt); + return callStmt; + } + case BlueprintNodeType.CallHelper: + { + if (node is not CallHelperNode callHelper) return null; + var helperArgs = _exportHelper.GetInputArgs(callHelper); + var expression = $"{callHelper.HelperFunctionName}({helperArgs})"; + var helperStmt = new ExpressionStatement + { + Expression = expression, + SourceCode = expression + ";", + LineNumber = 1 + }; + PostProcessCallReturn(node, helperStmt); + return helperStmt; + } + case BlueprintNodeType.BuiltinFunction: + { + if (node is BuiltinFunctionNode bfNode + && _builtinFunctionStrategies.TryGetValue(bfNode.FunctionName, out var bfStrategy)) + return bfStrategy.ToStatement(node, _exportHelper); + return null; + } + default: + break; + } + + if (!_strategies.TryGetValue(node.NodeType, out var strategy)) + { + Log.Warning("[CFGBuilderFromBlueprint] Unhandled node type: {NodeType}", node.NodeType); + return null; + } + + var stmt = strategy.ToStatement(node, _exportHelper); + if (stmt == null) return null; + + PostProcessCallReturn(node, stmt); + return stmt; + } + + private void PostProcessCallReturn(BlueprintNode node, BlockStatement stmt) + { + if (_currentCtx == null) return; + + if (node.NodeType is BlueprintNodeType.Call or BlueprintNodeType.CallHelper + && stmt is ExpressionStatement exprStmt) + { + var returnPin = node.OutputPins.FirstOrDefault(p => p.Name == Return); + bool hasReturn = returnPin != null && _currentCtx.ConsumedOutputs.Contains((node.Id, Return)); + if (hasReturn) + { + var pubVar = NodeExportHelper.FindOutputPubVar(node, Return, _currentCtx); + if (pubVar != null) + exprStmt.SourceCode = $"{pubVar} = {exprStmt.Expression};"; + } + } + } + + private BlockStatement? GenerateGetStatement(BlueprintNode node) + { + if (_currentCtx == null) return null; + + // Only generate a Get statement if the Get node's output is consumed + if (!_currentCtx.ConsumedOutputs.Contains((node.Id, Value))) + return null; + + var pubVar = NodeExportHelper.FindOutputPubVar(node, Value, _currentCtx); + if (pubVar == null) return null; + + if (!_strategies.TryGetValue(BlueprintNodeType.Get, out var strategy)) + return null; + + var stmt = strategy.ToStatement(node, _exportHelper); + if (stmt is ExpressionStatement exprStmt) + { + exprStmt.SourceCode = $"{pubVar} = {exprStmt.Expression};"; + return exprStmt; + } + return stmt; + } + + private ConversionContext? _currentCtx; + + /// + /// Sets the context for PubVar resolution during statement generation. + /// Called before Build() when BlockScopes path is used. + /// + public void SetContext(Contract.Workflow.Blueprint blueprint, ConversionContext? ctx) + { + _exportHelper.SetContext(blueprint, ctx); + _currentCtx = ctx; + } + + // ════════════════════════════════════════════════════════════════════ + // Statement Conversion Helpers + // ════════════════════════════════════════════════════════════════════ + + private static CFGStatement ConvertBlockStatementToCfgStatement(BlockStatement blockStmt, BlueprintNode node) + { + var cfgStmt = new CFGStatement + { + Id = node.Id, // Use node ID as statement ID for node mapping + OriginalExpression = blockStmt.SourceCode, + SourceLine = blockStmt.LineNumber, + }; + + switch (blockStmt) + { + case FlowControlStatement flow: + cfgStmt.Kind = flow.ControlType switch + { + FlowControlType.Branch => CFGStatementKind.Branch, + FlowControlType.Loop => CFGStatementKind.Loop, + FlowControlType.ToLoopCond => CFGStatementKind.ToLoopCond, + FlowControlType.Break => CFGStatementKind.Break, + _ => CFGStatementKind.Unknown + }; + cfgStmt.ConditionExpression = flow.ConditionExpression; + cfgStmt.TrueBlockName = flow.TrueBlockName; + cfgStmt.FalseBlockName = flow.FalseBlockName; + cfgStmt.ToLoopCondReturnTo = flow.ToLoopCondReturnTo; + break; + + case ExpressionStatement expr: + cfgStmt.Kind = CFGStatementKind.Expression; + cfgStmt.OriginalExpression = expr.SourceCode; + // Parse PubVar target from "pubVar = expr;" pattern + var sourceCode = expr.SourceCode; + var eqIdx = sourceCode.IndexOf(" = "); + if (eqIdx > 0 && sourceCode.StartsWith(expr.Expression.Split('.')[0])) + { + cfgStmt.PubVarTarget = sourceCode[..eqIdx]; + } + break; + + default: + cfgStmt.Kind = CFGStatementKind.Unknown; + break; + } + + return cfgStmt; + } + + private static CFGStatement CreateToLoopCondStatement(string? returnTo) + { + var stmt = new CFGStatement + { + Kind = CFGStatementKind.ToLoopCond, + ToLoopCondReturnTo = returnTo, + OriginalExpression = returnTo != null + ? $"NextBlock = ToLoopCond(\"{returnTo}\");" + : "NextBlock = ToLoopCond();", + SourceLine = 1 + }; + return stmt; + } + + // ════════════════════════════════════════════════════════════════════ + // Step 3.5: Resolve Branch/Loop Target Block Names + // ════════════════════════════════════════════════════════════════════ + + /// + /// After blocks are built, resolves the TrueBlockName/FalseBlockName of + /// Branch and Loop statements from Blueprint execution connections. + /// Strategy-generated statements don't know their target block names; + /// we derive them from the node's output pins → connections → target node → containing block. + /// + private static void ResolveControlFlowTargets( + ControlFlowGraph cfg, + Contract.Workflow.Blueprint blueprint, + Dictionary nodeById, + List execConns) + { + foreach (var block in cfg.Blocks) + { + foreach (var stmt in block.Statements) + { + if (stmt.Kind is not (CFGStatementKind.Branch or CFGStatementKind.Loop)) + continue; + if (!string.IsNullOrEmpty(stmt.TrueBlockName) && !string.IsNullOrEmpty(stmt.FalseBlockName)) + continue; + + // Find the Blueprint node corresponding to this statement + if (!nodeById.TryGetValue(stmt.Id, out var node)) + continue; + + if (stmt.Kind == CFGStatementKind.Branch) + ResolveBranchTargets(stmt, node, cfg, blueprint, execConns); + else if (stmt.Kind == CFGStatementKind.Loop) + ResolveLoopTargets(stmt, node, cfg, blueprint, execConns); + } + } + } + + private static void ResolveBranchTargets( + CFGStatement stmt, BlueprintNode branchNode, + ControlFlowGraph cfg, Contract.Workflow.Blueprint blueprint, + List execConns) + { + // True branch + if (string.IsNullOrEmpty(stmt.TrueBlockName)) + { + var truePin = branchNode.OutputPins.FirstOrDefault(p => p.Name == True); + if (truePin != null) + { + var conn = execConns.FirstOrDefault(c => c.SourcePinId == truePin.Id); + if (conn != null) + { + var targetBlock = FindBlockContainingNode(cfg, conn.TargetNodeId, blueprint); + if (targetBlock != null) + stmt.TrueBlockName = targetBlock; + } + } + } + + // False branch + if (string.IsNullOrEmpty(stmt.FalseBlockName)) + { + var falsePin = branchNode.OutputPins.FirstOrDefault(p => p.Name == False); + if (falsePin != null) + { + var conn = execConns.FirstOrDefault(c => c.SourcePinId == falsePin.Id); + if (conn != null) + { + var targetBlock = FindBlockContainingNode(cfg, conn.TargetNodeId, blueprint); + if (targetBlock != null) + stmt.FalseBlockName = targetBlock; + } + } + } + + // Regenerate source expression with resolved names + if (!string.IsNullOrEmpty(stmt.TrueBlockName) || !string.IsNullOrEmpty(stmt.FalseBlockName)) + stmt.OriginalExpression = RegenerateBranchSource(stmt); + } + + private static void ResolveLoopTargets( + CFGStatement stmt, BlueprintNode loopNode, + ControlFlowGraph cfg, Contract.Workflow.Blueprint blueprint, + List execConns) + { + // LoopBody (True) branch + if (string.IsNullOrEmpty(stmt.TrueBlockName)) + { + var loopBodyPin = loopNode.OutputPins.FirstOrDefault(p => p.Name == LoopBody); + if (loopBodyPin != null) + { + var conn = execConns.FirstOrDefault(c => c.SourcePinId == loopBodyPin.Id); + if (conn != null) + { + var targetBlock = FindBlockContainingNode(cfg, conn.TargetNodeId, blueprint); + if (targetBlock != null) + stmt.TrueBlockName = targetBlock; + } + } + } + + // LoopEnd (False) branch + if (string.IsNullOrEmpty(stmt.FalseBlockName)) + { + var loopEndPin = loopNode.OutputPins.FirstOrDefault(p => p.Name == LoopEnd); + if (loopEndPin != null) + { + var conn = execConns.FirstOrDefault(c => c.SourcePinId == loopEndPin.Id); + if (conn != null) + { + var targetBlock = FindBlockContainingNode(cfg, conn.TargetNodeId, blueprint); + if (targetBlock != null) + stmt.FalseBlockName = targetBlock; + } + } + } + + // Regenerate source expression with resolved names + if (!string.IsNullOrEmpty(stmt.TrueBlockName) || !string.IsNullOrEmpty(stmt.FalseBlockName)) + stmt.OriginalExpression = RegenerateBranchSource(stmt); + } + + // ════════════════════════════════════════════════════════════════════ + // Helper Methods + // ════════════════════════════════════════════════════════════════════ + + private static CFGStatement? FindBranchStatement(ControlFlowGraph cfg, string nodeId) + { + foreach (var block in cfg.Blocks) + { + foreach (var stmt in block.Statements) + { + if (stmt.Id == nodeId) + return stmt; + } + } + return null; + } + + private static string? FindContainingBlockName(ControlFlowGraph cfg, string nodeId) + { + foreach (var block in cfg.Blocks) + { + if (block.Statements.Any(s => s.Id == nodeId)) + return block.Name; + } + return null; + } + + private static string? FindBlockContainingNode(ControlFlowGraph cfg, string targetNodeId, + Contract.Workflow.Blueprint blueprint) + { + // Find which BlockScope contains the target node + foreach (var scope in blueprint.BlockScopes) + { + if (scope.NodeIds.Contains(targetNodeId)) + return scope.Name; + } + + // Fallback: find which CFG block contains a statement with this node ID + foreach (var block in cfg.Blocks) + { + if (block.Statements.Any(s => s.Id == targetNodeId)) + return block.Name; + } + + return null; + } + + private static string RegenerateBranchSource(CFGStatement branchStmt) + { + return branchStmt.Kind == CFGStatementKind.Loop + ? $"NextBlock = Loop({branchStmt.ConditionExpression}, \"{branchStmt.TrueBlockName}\", \"{branchStmt.FalseBlockName}\");" + : $"NextBlock = Branch({branchStmt.ConditionExpression}, \"{branchStmt.TrueBlockName}\", \"{branchStmt.FalseBlockName}\");"; + } + + // ─── Node Type Helpers ──────────────────────────────────────────── + + private static bool IsNodeType(BlueprintNode node, BlueprintNodeType type) => + node.NodeType == type || (node is BuiltinFunctionNode bfn && bfn.FunctionName == type.ToString()); + + private static bool IsBranchNode(BlueprintNode node) => IsNodeType(node, BlueprintNodeType.Branch); + + private static bool IsLoopNode(BlueprintNode node) => IsNodeType(node, BlueprintNodeType.Loop); + + private static bool IsBreakNode(BlueprintNode node) => IsNodeType(node, BlueprintNodeType.Break); + + private static bool IsGetNode(BlueprintNode node) => IsNodeType(node, BlueprintNodeType.Get); +} \ No newline at end of file diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGBuilderFromScript.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGBuilderFromScript.cs new file mode 100644 index 00000000..b3195ec7 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGBuilderFromScript.cs @@ -0,0 +1,295 @@ +using System.Collections.Generic; +using System.Linq; +using KitX.Core.Contract.Workflow; +using KitX.Core.Workflow.BlockScripting; + +using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Blocks; +using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Pins; + +namespace KitX.Core.Workflow.Blueprint.CFG; + +/// +/// Builds a from a AST. +/// This is the canonical transformation for the BS→BP pipeline, replacing the +/// ad-hoc intermediate. +/// +/// Strategy: uses the existing for expression +/// expansion, then converts the to a +/// with typed edges. +/// +internal class CFGBuilderFromScript +{ + private readonly List _helperFunctions; + private readonly BuiltinFunctionRegistry? _functionRegistry; + + public CFGBuilderFromScript(List helperFunctions, BuiltinFunctionRegistry? functionRegistry = null) + { + _helperFunctions = helperFunctions; + _functionRegistry = functionRegistry; + } + + /// + /// Builds a ControlFlowGraph from a parsed BlockScript. + /// The ScriptFormatter handles expression expansion; this method adds + /// CFG structure (blocks, edges, block types) and type classification. + /// + public ControlFlowGraph Build(BlockScript script, Pipeline.PipelineContext context) + { + // Phase 1: Use ScriptFormatter for expression expansion + var formatter = new Pipeline.ScriptFormatter(_helperFunctions, _functionRegistry); + var formatted = formatter.Format(script, context); + + // Phase 2: Convert FormattedBlockScript to ControlFlowGraph + var cfg = new ControlFlowGraph + { + HelperFunctions = _helperFunctions ?? [], + PubVarDeclarations = context.PubVarNames.ToList(), + }; + + // Convert ConstBlock variables + if (script.ConstBlock != null) + { + foreach (var varDecl in script.ConstBlock.Variables) + { + cfg.ConstDeclarations.Add(new ConstDeclaration + { + Name = varDecl.Name, + Type = varDecl.Type, + InitialValueExpression = varDecl.InitialValueExpression, + DefaultValue = varDecl.DefaultValue + }); + } + } + + // Convert PubVarBlock variables + if (script.PubVarBlock != null) + { + foreach (var varDecl in script.PubVarBlock.Variables) + { + if (!cfg.PubVarDeclarations.Contains(varDecl.Name)) + cfg.PubVarDeclarations.Add(varDecl.Name); + } + } + + // Convert formatted blocks to CFG blocks + var blockMap = new Dictionary(); + + foreach (var fmtBlock in formatted.Blocks) + { + var cfgBlock = new CFGBlock + { + Name = fmtBlock.Name, + Type = fmtBlock.Name == MainBlock ? CFGBlockType.Entry : ClassifyBlockType(fmtBlock), + NextBlockName = fmtBlock.NextBlockName, + }; + + // Convert statements + foreach (var fmtStmt in fmtBlock.Statements) + { + cfgBlock.Statements.Add(ConvertStatement(fmtStmt)); + } + + // Build edges from control flow statements + BuildEdgesFromBlock(cfgBlock, fmtBlock); + + cfg.Blocks.Add(cfgBlock); + blockMap[cfgBlock.Name] = cfgBlock; + + if (cfgBlock.IsMainBlock) + cfg.EntryBlock = cfgBlock; + } + + // Set EntryBlock if not set + if (cfg.EntryBlock == null && cfg.Blocks.Count > 0) + cfg.EntryBlock = cfg.Blocks[0]; + + // Set parent loop block names for ToLoopCond blocks + SetParentLoopReferences(cfg, script); + + // Transfer PubVar counter + cfg.PubVarCounter = context.NextPubVarCounter; + + return cfg; + } + + // ─── Statement Conversion ────────────────────────────────────────── + + private static CFGStatement ConvertStatement(Pipeline.FormattedStatement fmtStmt) + { + return new CFGStatement + { + Id = fmtStmt.StatementId, + BlockName = fmtStmt.BlockName, + Kind = ConvertKind(fmtStmt.Kind), + OriginalExpression = fmtStmt.OriginalExpression, + SourceLine = fmtStmt.SourceLine, + PubVarTarget = fmtStmt.PubVarTarget, + FunctionName = fmtStmt.FunctionName, + FullFunctionName = fmtStmt.FullFunctionName, + Arguments = fmtStmt.Arguments, + ConditionExpression = fmtStmt.ConditionExpression, + ConditionPubVar = fmtStmt.ConditionPubVar, + TrueBlockName = fmtStmt.TrueBlockName, + FalseBlockName = fmtStmt.FalseBlockName, + ToLoopCondReturnTo = fmtStmt.ToLoopCondReturnTo, + SetVarName = fmtStmt.SetVarName, + GetVarName = fmtStmt.GetVarName, + IsLoopConditionDuplication = fmtStmt.IsLoopConditionDuplication, + Fingerprint = fmtStmt.Fingerprint, + }; + } + + private static CFGStatementKind ConvertKind(Pipeline.FormattedStatementKind kind) => kind switch + { + Pipeline.FormattedStatementKind.Print => CFGStatementKind.Print, + Pipeline.FormattedStatementKind.Pause => CFGStatementKind.Pause, + Pipeline.FormattedStatementKind.Set => CFGStatementKind.Set, + Pipeline.FormattedStatementKind.Get => CFGStatementKind.Get, + Pipeline.FormattedStatementKind.Assignment => CFGStatementKind.Assignment, + Pipeline.FormattedStatementKind.Branch => CFGStatementKind.Branch, + Pipeline.FormattedStatementKind.Loop => CFGStatementKind.Loop, + Pipeline.FormattedStatementKind.ToLoopCond => CFGStatementKind.ToLoopCond, + Pipeline.FormattedStatementKind.Break => CFGStatementKind.Break, + Pipeline.FormattedStatementKind.NextBlockAssignment => CFGStatementKind.NextBlockAssignment, + Pipeline.FormattedStatementKind.Expression => CFGStatementKind.Expression, + _ => CFGStatementKind.Unknown, + }; + + // ─── Block Type Classification ────────────────────────────────────── + + private static CFGBlockType ClassifyBlockType(Pipeline.FormattedBlock fmtBlock) + { + if (fmtBlock.Name == MainBlock) + return CFGBlockType.Entry; + + // Classify based on the last statement + if (fmtBlock.Statements.Count == 0) + return CFGBlockType.Basic; + + var lastStmt = fmtBlock.Statements[^1]; + return lastStmt.Kind switch + { + Pipeline.FormattedStatementKind.Branch => CFGBlockType.BranchHeader, + Pipeline.FormattedStatementKind.Loop => CFGBlockType.LoopHeader, + _ => CFGBlockType.Basic + }; + } + + // ─── Edge Building ───────────────────────────────────────────────── + + private static void BuildEdgesFromBlock(CFGBlock cfgBlock, Pipeline.FormattedBlock fmtBlock) + { + // Sequential fall-through edge (NextBlock) + if (!string.IsNullOrEmpty(fmtBlock.NextBlockName) && !cfgBlock.EndsWithControlFlow) + { + cfgBlock.Successors.Add(new CFGEdge + { + FromBlockName = cfgBlock.Name, + ToBlockName = fmtBlock.NextBlockName!, + Type = CFGEdgeType.Sequential, + PinName = Exec + }); + } + + // Edges from control flow statements + foreach (var stmt in fmtBlock.Statements) + { + switch (stmt.Kind) + { + case Pipeline.FormattedStatementKind.Branch: + if (!string.IsNullOrEmpty(stmt.TrueBlockName)) + cfgBlock.Successors.Add(new CFGEdge + { + FromBlockName = cfgBlock.Name, + ToBlockName = stmt.TrueBlockName, + Type = CFGEdgeType.BranchTrue, + PinName = True + }); + if (!string.IsNullOrEmpty(stmt.FalseBlockName)) + cfgBlock.Successors.Add(new CFGEdge + { + FromBlockName = cfgBlock.Name, + ToBlockName = stmt.FalseBlockName, + Type = CFGEdgeType.BranchFalse, + PinName = False + }); + break; + + case Pipeline.FormattedStatementKind.Loop: + if (!string.IsNullOrEmpty(stmt.TrueBlockName)) + cfgBlock.Successors.Add(new CFGEdge + { + FromBlockName = cfgBlock.Name, + ToBlockName = stmt.TrueBlockName, + Type = CFGEdgeType.LoopBody, + PinName = LoopBody + }); + if (!string.IsNullOrEmpty(stmt.FalseBlockName)) + cfgBlock.Successors.Add(new CFGEdge + { + FromBlockName = cfgBlock.Name, + ToBlockName = stmt.FalseBlockName, + Type = CFGEdgeType.LoopExit, + PinName = LoopEnd + }); + break; + + case Pipeline.FormattedStatementKind.ToLoopCond: + if (!string.IsNullOrEmpty(stmt.ToLoopCondReturnTo)) + cfgBlock.Successors.Add(new CFGEdge + { + FromBlockName = cfgBlock.Name, + ToBlockName = stmt.ToLoopCondReturnTo, + Type = CFGEdgeType.LoopbackToCondition, + PinName = null // ToLoopCond doesn't map to a specific pin + }); + break; + + case Pipeline.FormattedStatementKind.Break: + // Break edges are resolved later during BP→BS conversion + cfgBlock.Successors.Add(new CFGEdge + { + FromBlockName = cfgBlock.Name, + ToBlockName = "__break__", // Placeholder, resolved during conversion + Type = CFGEdgeType.Break, + PinName = null + }); + break; + } + } + } + + // ─── Parent Loop References ──────────────────────────────────────── + + /// + /// Sets for blocks that are loop bodies. + /// This is derived from the original BlockScript's ToLoopCond statements, + /// which reference their parent loop condition block. + /// + private static void SetParentLoopReferences(ControlFlowGraph cfg, BlockScript script) + { + // Build a map: block containing ToLoopCond → target block name + var toLoopCondTargets = new Dictionary(); + + foreach (var block in cfg.Blocks) + { + foreach (var stmt in block.Statements) + { + if (stmt.Kind == CFGStatementKind.ToLoopCond && !string.IsNullOrEmpty(stmt.ToLoopCondReturnTo)) + { + toLoopCondTargets[block.Name] = stmt.ToLoopCondReturnTo; + } + } + } + + // For each block that has a ToLoopCond edge, set its ParentLoopBlockName + foreach (var block in cfg.Blocks) + { + var toLoopCondEdge = block.Successors.FirstOrDefault(e => e.Type == CFGEdgeType.LoopbackToCondition); + if (toLoopCondEdge != null) + { + block.ParentLoopBlockName = toLoopCondEdge.ToBlockName; + } + } + } +} \ No newline at end of file diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGConditionDuplicator.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGConditionDuplicator.cs new file mode 100644 index 00000000..e37adef7 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGConditionDuplicator.cs @@ -0,0 +1,97 @@ +using System.Collections.Generic; +using System.Linq; +using KitX.Core.Contract.Workflow; + +using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Pins; + +namespace KitX.Core.Workflow.Blueprint.CFG; + +/// +/// Duplicates Loop condition evaluation statements before each ToLoopCond +/// in loop body blocks. This is a pure CFG transformation — it doesn't depend +/// on the walk strategy or any external context. +/// +internal class CFGConditionDuplicator +{ + /// + /// Duplicates loop condition statements before each ToLoopCond in the CFG. + /// This is the BP→BS Phase 2 (condition duplication). + /// + public void Duplicate(ControlFlowGraph cfg) + { + // For each LoopHeader block, find its condition evaluation statements + foreach (var block in cfg.Blocks.Where(b => b.Type == CFGBlockType.LoopHeader)) + { + var loopStmt = block.Statements.FirstOrDefault(s => + s.Kind == CFGStatementKind.Loop); + if (loopStmt == null) continue; + + // Find the condition evaluation statements (everything before the Loop statement) + var loopIndex = block.Statements.IndexOf(loopStmt); + if (loopIndex <= 0) continue; + + var condStmts = block.Statements.Take(loopIndex) + .Where(s => s.Kind is CFGStatementKind.Assignment or CFGStatementKind.Get or CFGStatementKind.Expression) + .ToList(); + + if (condStmts.Count == 0) continue; + + // Find all blocks that have a ToLoopCond edge back to this LoopHeader + var loopBodyBlocks = cfg.Blocks.Where(b => + b.Successors.Any(e => e.Type == CFGEdgeType.LoopbackToCondition && e.ToBlockName == block.Name)); + + foreach (var bodyBlock in loopBodyBlocks) + { + InsertConditionDuplicates(bodyBlock, condStmts); + } + } + } + + /// + /// Inserts condition evaluation duplicates before each ToLoopCond statement + /// in the body block. + /// + private static void InsertConditionDuplicates(CFGBlock bodyBlock, List condStmts) + { + var insertions = new List<(int index, List stmts)>(); + + for (int i = 0; i < bodyBlock.Statements.Count; i++) + { + var stmt = bodyBlock.Statements[i]; + if (stmt.Kind == CFGStatementKind.ToLoopCond) + { + var dupStmts = condStmts.Select(CloneStatement).ToList(); + insertions.Add((i, dupStmts)); + } + } + + // Apply insertions in reverse order to preserve indices + foreach (var (index, stmts) in insertions.OrderByDescending(x => x.index)) + bodyBlock.Statements.InsertRange(index, stmts); + } + + /// + /// Clones a CFGStatement, marking it as a condition duplication. + /// + private static CFGStatement CloneStatement(CFGStatement source) => new() + { + Id = source.Id, // Same ID — these are duplicates of the same logical statement + BlockName = source.BlockName, + Kind = source.Kind, + OriginalExpression = source.OriginalExpression, + SourceLine = source.SourceLine, + PubVarTarget = source.PubVarTarget, + FunctionName = source.FunctionName, + FullFunctionName = source.FullFunctionName, + Arguments = source.Arguments, + ConditionExpression = source.ConditionExpression, + ConditionPubVar = source.ConditionPubVar, + TrueBlockName = source.TrueBlockName, + FalseBlockName = source.FalseBlockName, + ToLoopCondReturnTo = source.ToLoopCondReturnTo, + SetVarName = source.SetVarName, + GetVarName = source.GetVarName, + Fingerprint = source.Fingerprint, + IsLoopConditionDuplication = true + }; +} \ No newline at end of file diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGEdge.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGEdge.cs new file mode 100644 index 00000000..5f2169f1 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGEdge.cs @@ -0,0 +1,78 @@ +namespace KitX.Core.Workflow.Blueprint.CFG; + +/// +/// Type of a control flow edge between blocks. Making edge semantics +/// explicit eliminates the need for heuristic-based control flow resolution. +/// +internal enum CFGEdgeType +{ + /// + /// Sequential fall-through (NextBlock assignment). + /// The current block ends normally and transfers to the next block. + /// + Sequential, + + /// + /// Branch condition evaluates to true. + /// Maps to Blueprint's True output pin on a Branch node. + /// + BranchTrue, + + /// + /// Branch condition evaluates to false. + /// Maps to Blueprint's False output pin on a Branch node. + /// + BranchFalse, + + /// + /// Loop condition evaluates to true — enter loop body. + /// Maps to Blueprint's LoopBody output pin on a Loop node. + /// + LoopBody, + + /// + /// Loop condition evaluates to false — exit loop. + /// Maps to Blueprint's LoopEnd output pin on a Loop node. + /// + LoopExit, + + /// + /// Return to the loop condition block from the loop body (ToLoopCond). + /// This is a back-edge in the CFG that doesn't map to a single connection + /// but represents the loop's iterative structure. + /// + LoopbackToCondition, + + /// + /// Break from the current loop — exits to the loop's exit block. + /// + Break, +} + +/// +/// A typed edge in the Control Flow Graph, capturing the semantics +/// of the transition between two blocks. +/// +internal class CFGEdge +{ + /// + /// The block this edge originates from. + /// + public required string FromBlockName { get; set; } + + /// + /// The block this edge targets. + /// + public required string ToBlockName { get; set; } + + /// + /// The semantic type of this edge. + /// + public required CFGEdgeType Type { get; set; } + + /// + /// The corresponding Blueprint pin name (e.g., "True", "False", "LoopBody", "LoopEnd", "Exec"). + /// Null for edges that don't map to a specific pin (e.g., LoopbackToCondition). + /// + public string? PinName { get; set; } +} \ No newline at end of file diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGStatement.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGStatement.cs new file mode 100644 index 00000000..d38099ef --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGStatement.cs @@ -0,0 +1,155 @@ +namespace KitX.Core.Workflow.Blueprint.CFG; + +/// +/// Kinds of statements in the CFG. Mirrors +/// but with stronger semantics — every statement in the CFG is classified. +/// +internal enum CFGStatementKind +{ + /// Unknown or unclassified + Unknown, + + /// Print(expr) + Print, + + /// Pause(ms) + Pause, + + /// Set("varName", expr) + Set, + + /// Get("varName") — standalone or as part of a PubVar assignment + Get, + + /// pubVar = FunctionCall(args...) + Assignment, + + /// Branch(condition, trueBlock, falseBlock) + Branch, + + /// Loop(condition, loopBody, afterLoop) + Loop, + + /// ToLoopCond("parentBlock") + ToLoopCond, + + /// Break() + Break, + + /// NextBlock = ... (handled internally, no node created) + NextBlockAssignment, + + /// Plain expression without assignment + Expression, +} + +/// +/// A single statement within a CFG block. All expressions are flat — +/// nested calls have been expanded into sequential PubVar assignments. +/// Carries all information needed to produce a Blueprint node or a +/// BlockScript statement. +/// +internal class CFGStatement +{ + /// + /// Unique identifier for this statement. Used to link CFG statements + /// to Blueprint nodes during conversion. + /// + public string Id { get; set; } = Guid.NewGuid().ToString(); + + /// + /// The block this statement belongs to. + /// + public string BlockName { get; set; } = string.Empty; + + /// + /// What kind of statement this is. + /// + public CFGStatementKind Kind { get; set; } + + /// + /// The original source expression for this statement. + /// Used for BlockScript serialization and debugging. + /// + public string OriginalExpression { get; set; } = string.Empty; + + /// + /// Source line number in the original script. + /// + public int SourceLine { get; set; } + + // --- For Assignment / Get / Set --- + /// + /// The PubVar being assigned (e.g. "vaaa0001"), if this is an assignment. + /// + public string? PubVarTarget { get; set; } + + // --- For function calls --- + /// + /// Function name (e.g. "HelperFuncCompare", "Get", "Set", "Print"). + /// For plugin calls, this is the short name. + /// + public string? FunctionName { get; set; } + + /// + /// Full dotted method path for plugin/external calls + /// (e.g. "TestPlugin.WPF.Core.HelloKitX"). + /// Null for built-in and helper functions. + /// + public string? FullFunctionName { get; set; } + + /// + /// Raw argument strings after expansion (no nested calls). + /// Each argument is either a literal, a PubVar name, a ConstBlock variable name, + /// or Get("varName"). + /// + public List Arguments { get; set; } = []; + + // --- For flow control --- + /// + /// The condition expression for Branch/Loop statements. + /// May be a PubVar name or a complex expression. + /// + public string? ConditionExpression { get; set; } + + /// + /// The PubVar holding the condition result, if pre-computed. + /// + public string? ConditionPubVar { get; set; } + + /// + /// Target block name when Branch/Loop condition is true. + /// + public string? TrueBlockName { get; set; } + + /// + /// Target block name when Branch/Loop condition is false. + /// + public string? FalseBlockName { get; set; } + + /// + /// For ToLoopCond: the name of the loop condition block to return to. + /// + public string? ToLoopCondReturnTo { get; set; } + + // --- For Set / Get --- + /// The variable name being set (for Set statements). + public string? SetVarName { get; set; } + + /// The variable name being read (for Get statements). + public string? GetVarName { get; set; } + + // --- Metadata --- + /// + /// True if this statement was inserted as a Loop condition duplication + /// before a ToLoopCond statement. These are not present in the original + /// script but are needed for correct loop execution semantics. + /// + public bool IsLoopConditionDuplication { get; set; } + + /// + /// Expression fingerprint for PubVar reuse detection. + /// Same expression → same fingerprint → nodes can be shared. + /// + public string? Fingerprint { get; set; } +} \ No newline at end of file diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/ConstDeclaration.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/ConstDeclaration.cs new file mode 100644 index 00000000..ee2e2ef1 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/ConstDeclaration.cs @@ -0,0 +1,34 @@ +namespace KitX.Core.Workflow.Blueprint.CFG; + +/// +/// A constant declaration from #ConstBlock. Preserves both the raw source +/// representation () and the evaluated +/// value () to ensure correct round-tripping +/// of string/char literals. +/// +internal class ConstDeclaration +{ + /// Variable name (e.g. "bfCode", "memorySize"). + public string Name { get; set; } = string.Empty; + + /// Type name (e.g. "string", "int", "char"). + public string Type { get; set; } = "object"; + + /// + /// The raw C# source representation of the initial value expression. + /// Preserves quoting and escaping (e.g. "\"hello\"", "'\0'"). + /// Preferred over for serialization. + /// + public string? InitialValueExpression { get; set; } + + /// + /// The evaluated .NET value of the constant. For strings, this is + /// the string content WITHOUT quotes. For chars, the char value. + /// Used during execution but NOT for serialization (use + /// instead to preserve quotes). + /// + public object? DefaultValue { get; set; } + + /// Whether this constant has an initial value assignment. + public bool HasInitialValue => DefaultValue != null || !string.IsNullOrEmpty(InitialValueExpression); +} \ No newline at end of file diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/ControlFlowGraph.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/ControlFlowGraph.cs new file mode 100644 index 00000000..4e3bfb8a --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/ControlFlowGraph.cs @@ -0,0 +1,91 @@ +using KitX.Core.Contract.Workflow; +using System.Text; + +namespace KitX.Core.Workflow.Blueprint.CFG; + +/// +/// The Control Flow Graph — canonical intermediate representation for +/// both BS→BP and BP→BS conversion. Both directions produce/consume +/// a CFG, ensuring round-trip fidelity by structural equivalence. +/// +internal class ControlFlowGraph +{ + /// + /// All blocks in the CFG, ordered: MainBlock first, then named blocks + /// in definition order. + /// + public List Blocks { get; set; } = []; + + /// + /// The entry block (MainBlock equivalent). + /// + public CFGBlock EntryBlock { get; set; } = null!; + + /// + /// Public variable names declared in #PubVarBlock. + /// + public List PubVarDeclarations { get; set; } = []; + + /// + /// Constant declarations from #ConstBlock. + /// + public List ConstDeclarations { get; set; } = []; + + /// + /// Helper functions available in the script. + /// + public List HelperFunctions { get; set; } = []; + + /// + /// Counter for generating unique PubVar names (vaaa0001, vaaa0002, ...). + /// + public int PubVarCounter { get; set; } = 1; + + /// + /// Gets a block by name. Returns null if not found. + /// + public CFGBlock? GetBlock(string name) + => Blocks.FirstOrDefault(b => b.Name == name); + + /// + /// Generates the next unique PubVar name. + /// + public string NextPubVarName() + => $"vaaa{PubVarCounter++:D4}"; + + /// + /// Dumps the CFG as a human-readable string for diagnostics. + /// + public string Dump() + { + var sb = new StringBuilder(); + sb.AppendLine($" CFG: {Blocks.Count} blocks, entry={EntryBlock?.Name}"); + sb.AppendLine($" PubVars: [{string.Join(", ", PubVarDeclarations)}]"); + sb.AppendLine($" Consts: {ConstDeclarations.Count}"); + + foreach (var block in Blocks) + { + sb.AppendLine($" ── Block \"{block.Name}\" (Type={block.Type}, IsMain={block.IsMainBlock}, NextBlock={block.NextBlockName ?? "null"}, ParentLoop={block.ParentLoopBlockName ?? "null"})"); + foreach (var stmt in block.Statements) + { + var dup = stmt.IsLoopConditionDuplication ? " [COND_DUP]" : ""; + sb.AppendLine($" [{stmt.Kind}] {stmt.OriginalExpression}{dup}"); + if (!string.IsNullOrEmpty(stmt.TrueBlockName)) + sb.AppendLine($" → True=\"{stmt.TrueBlockName}\", False=\"{stmt.FalseBlockName}\""); + if (!string.IsNullOrEmpty(stmt.ToLoopCondReturnTo)) + sb.AppendLine($" → ToLoopCond=\"{stmt.ToLoopCondReturnTo}\""); + if (!string.IsNullOrEmpty(stmt.PubVarTarget)) + sb.AppendLine($" PubVarTarget={stmt.PubVarTarget}"); + } + + if (block.Successors.Count > 0) + { + sb.AppendLine($" Edges:"); + foreach (var edge in block.Successors) + sb.AppendLine($" {edge.Type}: {edge.FromBlockName} → {edge.ToBlockName} (pin={edge.PinName ?? "-"})"); + } + } + + return sb.ToString(); + } +} \ No newline at end of file diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/ScriptGenerator.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/ScriptGenerator.cs new file mode 100644 index 00000000..b7e714e0 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/ScriptGenerator.cs @@ -0,0 +1,192 @@ +using System.Linq; +using KitX.Core.Contract.Workflow; + +using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Blocks; + +namespace KitX.Core.Workflow.Blueprint.CFG; + +/// +/// Generates a from a . +/// This is the deterministic BP→BS Phase 3 — every CFGStatement becomes a +/// BlockStatement, and CFGEdges control flow is reconstructed as +/// FlowControlStatements and NextBlockName assignments. +/// +/// Replaces the former ad-hoc logic with a single, principled transformation from CFG to BlockScript. +/// with a single, principled transformation from CFG to BlockScript. +/// +internal class ScriptGenerator +{ + /// + /// Generates a BlockScript from a ControlFlowGraph. + /// + public BlockScript Generate(ControlFlowGraph cfg) + { + var script = new BlockScript + { + SourceCode = string.Empty // Filled by serializer + }; + + // ── ConstBlock ── + if (cfg.ConstDeclarations.Count > 0) + { + var constBlock = new BlockDefinition + { + Type = BlockType.ConstBlock, + Name = ConstBlock + }; + + foreach (var decl in cfg.ConstDeclarations) + { + constBlock.Variables.Add(new VariableDeclaration + { + Name = decl.Name, + Type = decl.Type, + DefaultValue = decl.DefaultValue, + InitialValueExpression = decl.InitialValueExpression + }); + } + + script.ConstBlock = constBlock; + } + + // ── PubVarBlock ── + if (cfg.PubVarDeclarations.Count > 0) + { + var pubVarBlock = new BlockDefinition + { + Type = BlockType.PubVarBlock, + Name = PubVarBlock + }; + + foreach (var pubVarName in cfg.PubVarDeclarations) + { + pubVarBlock.Variables.Add(new VariableDeclaration + { + Name = pubVarName, + Type = "dynamic" + }); + } + + script.PubVarBlock = pubVarBlock; + } + + // ── MainBlock + NamedBlocks ── + foreach (var cfgBlock in cfg.Blocks) + { + var blockDef = new BlockDefinition + { + Type = cfgBlock.IsMainBlock ? BlockType.MainBlock : BlockType.NamedBlock, + Name = cfgBlock.Name, + NextBlockName = cfgBlock.NextBlockName + }; + + foreach (var cfgStmt in cfgBlock.Statements) + { + var blockStmt = ConvertStatement(cfgStmt); + if (blockStmt != null) + blockDef.Statements.Add(blockStmt); + } + + if (cfgBlock.IsMainBlock) + script.MainBlock = blockDef; + else + script.NamedBlocks[cfgBlock.Name] = blockDef; + } + + // ── AllBlocks ── + if (script.MainBlock != null) + script.AllBlocks.Add(script.MainBlock); + foreach (var kvp in script.NamedBlocks) + script.AllBlocks.Add(kvp.Value); + + return script; + } + + // ─── Statement Conversion ────────────────────────────────────────── + + private static BlockStatement? ConvertStatement(CFGStatement cfgStmt) + { + switch (cfgStmt.Kind) + { + case CFGStatementKind.Branch: + return new FlowControlStatement + { + ControlType = FlowControlType.Branch, + ConditionExpression = cfgStmt.ConditionExpression ?? string.Empty, + TrueBlockName = cfgStmt.TrueBlockName ?? string.Empty, + FalseBlockName = cfgStmt.FalseBlockName ?? string.Empty, + SourceCode = cfgStmt.OriginalExpression, + LineNumber = cfgStmt.SourceLine + }; + + case CFGStatementKind.Loop: + return new FlowControlStatement + { + ControlType = FlowControlType.Loop, + ConditionExpression = cfgStmt.ConditionExpression ?? string.Empty, + TrueBlockName = cfgStmt.TrueBlockName ?? string.Empty, + FalseBlockName = cfgStmt.FalseBlockName ?? string.Empty, + SourceCode = cfgStmt.OriginalExpression, + LineNumber = cfgStmt.SourceLine + }; + + case CFGStatementKind.ToLoopCond: + return new FlowControlStatement + { + ControlType = FlowControlType.ToLoopCond, + ToLoopCondReturnTo = cfgStmt.ToLoopCondReturnTo, + SourceCode = cfgStmt.OriginalExpression, + LineNumber = cfgStmt.SourceLine + }; + + case CFGStatementKind.Break: + return new FlowControlStatement + { + ControlType = FlowControlType.Break, + SourceCode = cfgStmt.OriginalExpression, + LineNumber = cfgStmt.SourceLine + }; + + case CFGStatementKind.Print: + case CFGStatementKind.Pause: + case CFGStatementKind.Set: + case CFGStatementKind.Get: + case CFGStatementKind.Assignment: + case CFGStatementKind.Expression: + return new ExpressionStatement + { + Expression = ExtractExpression(cfgStmt.OriginalExpression), + SourceCode = cfgStmt.OriginalExpression, + LineNumber = cfgStmt.SourceLine + }; + + case CFGStatementKind.NextBlockAssignment: + // NextBlock assignments are handled via block.NextBlockName, not as statements + return null; + + default: + // Unknown statement — emit as ExpressionStatement + if (!string.IsNullOrEmpty(cfgStmt.OriginalExpression)) + { + return new ExpressionStatement + { + Expression = ExtractExpression(cfgStmt.OriginalExpression), + SourceCode = cfgStmt.OriginalExpression, + LineNumber = cfgStmt.SourceLine + }; + } + return null; + } + } + + /// + /// Extracts the expression from a statement (removes trailing semicolon). + /// + private static string ExtractExpression(string sourceCode) + { + var trimmed = sourceCode.TrimEnd(); + if (trimmed.EndsWith(';')) + return trimmed[..^1]; + return trimmed; + } +} \ No newline at end of file diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/ScriptSerializer.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/ScriptSerializer.cs new file mode 100644 index 00000000..03383c1e --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/ScriptSerializer.cs @@ -0,0 +1,119 @@ +using System.Linq; +using System.Text; +using KitX.Core.Contract.Workflow; + +using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Blocks; + +namespace KitX.Core.Workflow.Blueprint.CFG; + +/// +/// Serializes a to source code string. +/// This is the deterministic BP→BS Phase 4 — pure string assembly, +/// no logic. +/// +/// NextBlock assignment is derived from +/// using +/// , not from ad-hoc patches. +/// +internal class ScriptSerializer +{ + /// + /// Serializes a BlockScript to source code. + /// + public string Serialize(BlockScript script) + { + var sb = new StringBuilder(); + + // ── #ConstBlock ── + if (script.ConstBlock != null && script.ConstBlock.Variables.Count > 0) + { + sb.AppendLine(MarkerConstBlock); + foreach (var variable in script.ConstBlock.Variables) + { + if (variable.DefaultValue != null) + { + // Prefer InitialValueExpression (preserves original C# source with + // proper quoting/escaping) over DefaultValue (raw .NET object). + var initExpr = !string.IsNullOrEmpty(variable.InitialValueExpression) + ? variable.InitialValueExpression + : variable.Type switch + { + "string" => variable.DefaultValue is string s && s.Length == 0 + ? "\"\"" + : $"\"{variable.DefaultValue}\"", + "char" => variable.DefaultValue?.ToString() ?? string.Empty, + _ => variable.DefaultValue.ToString()! + }; + sb.AppendLine($"{variable.Type} {variable.Name} = {initExpr};"); + } + else + { + sb.AppendLine($"{variable.Type} {variable.Name};"); + } + } + sb.AppendLine(); + } + + // ── #PubVarBlock ── + if (script.PubVarBlock != null && script.PubVarBlock.Variables.Count > 0) + { + sb.AppendLine(MarkerPubVarBlock); + foreach (var variable in script.PubVarBlock.Variables) + { + sb.AppendLine($"{variable.Type} {variable.Name};"); + } + sb.AppendLine(); + } + + // ── #MainBlock ── + if (script.MainBlock != null) + { + sb.AppendLine(MarkerMainBlock); + AppendBlockStatements(sb, script.MainBlock); + sb.AppendLine(); + } + + // ── Named blocks ── + foreach (var kvp in script.NamedBlocks) + { + sb.AppendLine($"{MarkerBlockPrefix}{kvp.Key}"); + AppendBlockStatements(sb, kvp.Value); + sb.AppendLine(); + } + + return sb.ToString().TrimEnd(); + } + + /// + /// Appends block statements and, if the block has a NextBlockName and doesn't + /// end with a control flow statement, appends a NextBlock assignment. + /// + private static void AppendBlockStatements(StringBuilder sb, BlockDefinition block) + { + foreach (var stmt in block.Statements) + sb.AppendLine(stmt.SourceCode); + + // If the block has a fall-through NextBlockName and the last statement + // isn't already a control flow statement, add a NextBlock assignment. + if (!string.IsNullOrEmpty(block.NextBlockName) && !EndsWithControlFlow(block)) + sb.AppendLine($"NextBlock = \"{block.NextBlockName}\";"); + } + + /// + /// Checks whether the block's last statement is a control flow terminator + /// (Branch, Loop, ToLoopCond, Break) which already specifies the next block. + /// + private static bool EndsWithControlFlow(BlockDefinition block) + { + if (block.Statements.Count == 0) return false; + var last = block.Statements[^1]; + if (last is FlowControlStatement flow) + { + return flow.ControlType is FlowControlType.Branch + or FlowControlType.Loop + or FlowControlType.ToLoopCond + or FlowControlType.Break; + } + return false; + } +} \ No newline at end of file diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ReversePipeline/ReverseConversionContext.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ConversionContext.cs similarity index 83% rename from KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ReversePipeline/ReverseConversionContext.cs rename to KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ConversionContext.cs index 3ba2ff9d..2c010b08 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ReversePipeline/ReverseConversionContext.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ConversionContext.cs @@ -1,18 +1,18 @@ using System.Collections.Generic; using KitX.Core.Contract.Workflow; -namespace KitX.Core.Workflow.Blueprint.ReversePipeline; +namespace KitX.Core.Workflow.Blueprint; /// -/// Accumulated state for the reverse conversion (Blueprint → BlockScript). -/// Shared across all reverse pipeline phases. +/// Accumulated state for Blueprint → BlockScript conversion. +/// Shared across all CFG pipeline phases. /// -internal class ReverseConversionContext +internal class ConversionContext { public required Contract.Workflow.Blueprint Blueprint { get; set; } public required BlockScript Script { get; set; } - // Phase 1: Analysis + // Phase 1: Connection analysis public Dictionary NodeById { get; set; } = new(); public List ExecConnections { get; set; } = new(); public List DataConnections { get; set; } = new(); @@ -23,7 +23,7 @@ internal class ReverseConversionContext public int PubVarCounter { get; set; } = 1; public int BlockCounter { get; set; } = 0; - // Phase 2: Execution walk + // Phase 2: CFG block construction public Dictionary ControlFlowMap { get; set; } = new(); public List PendingControlFlowNodes { get; set; } = new(); public Dictionary LoopNodes { get; set; } = new(); @@ -35,13 +35,11 @@ internal class ReverseConversionContext /// /// Current loopback target ID when walking inside a loop body. - /// Set by ProcessLoopSubGraphs, read by ProcessBranchSubGraphs. /// public string? CurrentLoopbackTargetId { get; set; } /// /// Maps loop node ID to the block name that contains the Loop statement. - /// Used during topology-based reverse walk to generate correct ToLoopCond arguments. /// public Dictionary LoopOwnerBlockNames { get; set; } = new(); } @@ -56,4 +54,4 @@ internal class DataEdgeInfo public BlueprintPin SourcePin { get; set; } = null!; public string? PubVarName { get; set; } public BlueprintConnection Connection { get; set; } = null!; -} +} \ No newline at end of file diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExecutionFlowWalker.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExecutionFlowWalker.cs deleted file mode 100644 index bf5175f2..00000000 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ExecutionFlowWalker.cs +++ /dev/null @@ -1,667 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.BlockScripting; -using KitX.Core.Workflow.Blueprint.ReversePipeline; -using Serilog; - -using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Pins; -using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Blocks; - -namespace KitX.Core.Workflow.Blueprint; - -/// -/// Phase 2 of bp2bs: walks the Blueprint execution flow graph and generates -/// BlockScript statements. Handles both BlockScopes-based and topology-based paths. -/// Extracted from BlueprintToBlockScriptConverter for single-responsibility. -/// -internal class ExecutionFlowWalker -{ - private readonly Dictionary _strategies; - private readonly Dictionary _builtinFunctionStrategies; - private readonly NodeExportHelper _exportHelper; - - public ExecutionFlowWalker( - Dictionary strategies, - Dictionary builtinFunctionStrategies, - NodeExportHelper exportHelper) - { - _strategies = strategies; - _builtinFunctionStrategies = builtinFunctionStrategies; - _exportHelper = exportHelper; - } - - /// - /// Walks execution flow starting from Entry node (topology-based path). - /// Creates MainBlock and walks the execution graph depth-first. - /// - public void WalkExecutionFlow(ReverseConversionContext ctx) - { - var entryNode = ctx.Blueprint.Nodes.FirstOrDefault(n => - n.NodeType == BlueprintNodeType.Entry || n.NodeType == BlueprintNodeType.PluginTrigger); - if (entryNode == null) - { - Log.Warning("[ExecutionFlowWalker] No Entry or PluginTrigger node found"); - return; - } - - var mainBlock = new BlockDefinition { Type = BlockType.MainBlock, Name = BlockScriptWellKnown.Blocks.MainBlock }; - ctx.Script.MainBlock = mainBlock; - - var visited = new HashSet(); - WalkNode(entryNode, mainBlock, ctx, visited, loopbackTargetId: null); - - ProcessSubGraphs(ctx); - } - - /// - /// Generates statements from stored BlockScopes (BlockScopes-based path). - /// Processes each scope, generates statements for its nodes, resolves control flow, - /// and inserts ToLoopCond statements. Nodes not reachable from Entry via execution - /// edges are excluded. - /// - public void WalkFromBlockScopes(Contract.Workflow.Blueprint blueprint, ReverseConversionContext ctx) - { - // Build scopesByName lookup - foreach (var scope in blueprint.BlockScopes) - ctx.ScopesByName[scope.Name] = scope; - - // Reachability filter: only include nodes reachable from Entry via exec edges - var reachableNodeIds = FindReachableNodeIds(blueprint, ctx); - - // Generate statements from stored block membership - foreach (var scope in blueprint.BlockScopes) - { - BlockDefinition block; - if (scope.IsMainBlock) - { - block = new BlockDefinition { Type = BlockType.MainBlock, Name = BlockScriptWellKnown.Blocks.MainBlock }; - ctx.Script.MainBlock = block; - } - else - { - block = new BlockDefinition { Type = BlockType.NamedBlock, Name = scope.Name }; - ctx.Script.NamedBlocks[scope.Name] = block; - } - - foreach (var nodeId in scope.NodeIds) - { - // Skip nodes not reachable from Entry via execution edges - if (!reachableNodeIds.Contains(nodeId)) continue; - - if (!ctx.NodeById.TryGetValue(nodeId, out var node)) continue; - var stmt = GenerateStatement(node, ctx); - if (stmt != null) - { - block.Statements.Add(stmt); - if (stmt is FlowControlStatement flow) - { - ctx.ControlFlowMap[node.Id] = flow; - if (flow.ControlType == FlowControlType.Loop) - ctx.LoopNodes[node.Id] = node; - } - } - } - } - - // Resolve control flow block names from stored ownership - foreach (var scope in blueprint.BlockScopes) - { - if (scope.OwnerNodeId == null) continue; - if (!ctx.ControlFlowMap.TryGetValue(scope.OwnerNodeId, out var flow)) continue; - if (scope.OwnerArmName == BlockScriptWellKnown.Pins.True || scope.OwnerArmName == BlockScriptWellKnown.Pins.LoopBody) - flow.TrueBlockName = scope.Name; - else if (scope.OwnerArmName == BlockScriptWellKnown.Pins.False || scope.OwnerArmName == BlockScriptWellKnown.Pins.LoopEnd) - flow.FalseBlockName = scope.Name; - } - - foreach (var flow in ctx.ControlFlowMap.Values) - flow.RegenerateSourceCode(); - - // Detect loopback edges: if an exec connection goes from a scope's last node - // to a scope containing a Loop node (and no ToLoopCond was generated), - // insert a ToLoopCond statement. This is a fallback for when ToLoopCond - // nodes are absent (e.g. Canvas round-trip losing BuiltinFunctionNode metadata). - DetectAndInsertLoopbackToLoopConds(blueprint, ctx); - } - - // ─── Reachability Analysis ────────────────────────────────────────── - - /// - /// Performs BFS from the Entry node following only execution output pins - /// to determine which nodes are reachable from Entry. Nodes not in the - /// returned set should be excluded from BS output. - /// - private static HashSet FindReachableNodeIds( - Contract.Workflow.Blueprint blueprint, ReverseConversionContext ctx) - { - var reachable = new HashSet(); - var entry = blueprint.Nodes.FirstOrDefault(n => - n.NodeType == BlueprintNodeType.Entry || n.NodeType == BlueprintNodeType.PluginTrigger); - if (entry == null) return reachable; - - var queue = new Queue(); - queue.Enqueue(entry); - reachable.Add(entry.Id); - - while (queue.Count > 0) - { - var current = queue.Dequeue(); - foreach (var pin in current.OutputPins) - { - if (pin.Type != PinType.Execution) continue; - var conn = ctx.ExecConnections.FirstOrDefault(c => c.SourcePinId == pin.Id); - if (conn == null) continue; - var target = blueprint.GetNodeById(conn.TargetNodeId); - if (target == null || reachable.Contains(target.Id)) continue; - reachable.Add(target.Id); - queue.Enqueue(target); - } - } - - return reachable; - } - - // ─── Node Walking ──────────────────────────────────────────────────── - - private void WalkNode(BlueprintNode node, BlockDefinition currentBlock, - ReverseConversionContext ctx, HashSet visited, string? loopbackTargetId) - { - if (visited.Contains(node.Id)) return; - visited.Add(node.Id); - - if (loopbackTargetId != null && node.Id == loopbackTargetId) - { - var ownerName = ctx.LoopOwnerBlockNames.TryGetValue(loopbackTargetId, out var n) ? n : null; - currentBlock.Statements.Add(CreateToLoopCondStatement(ownerName)); - return; - } - - var stmt = GenerateStatement(node, ctx); - if (stmt != null) - { - currentBlock.Statements.Add(stmt); - if (stmt is FlowControlStatement flow) - { - ctx.ControlFlowMap[node.Id] = flow; - if (flow.ControlType == FlowControlType.Loop) - { - ctx.LoopNodes[node.Id] = node; - ctx.LoopOwnerBlockNames[node.Id] = currentBlock.Name; - } - } - } - - // If this node generated a ToLoopCond statement, don't follow exec chain - if (stmt is FlowControlStatement { ControlType: FlowControlType.ToLoopCond }) - return; - - if (IsBranchNode(node) || IsLoopNode(node)) - { - ctx.PendingControlFlowNodes.Add(node); - return; - } - - var execOut = node.OutputPins.FirstOrDefault(p => p.Name == Exec); - if (execOut == null) - { - if (loopbackTargetId != null) - { - var ownerName2 = ctx.LoopOwnerBlockNames.TryGetValue(loopbackTargetId, out var n2) ? n2 : null; - currentBlock.Statements.Add(CreateToLoopCondStatement(ownerName2)); - } - return; - } - - var execConn = ctx.ExecConnections.FirstOrDefault(c => c.SourcePinId == execOut.Id); - if (execConn == null) - { - if (loopbackTargetId != null) - { - var ownerName3 = ctx.LoopOwnerBlockNames.TryGetValue(loopbackTargetId, out var n3) ? n3 : null; - currentBlock.Statements.Add(CreateToLoopCondStatement(ownerName3)); - } - return; - } - - var nextNode = ctx.Blueprint.GetNodeById(execConn.TargetNodeId); - if (nextNode == null) return; - - if (loopbackTargetId != null && nextNode.Id == loopbackTargetId) - { - var ownerName4 = ctx.LoopOwnerBlockNames.TryGetValue(loopbackTargetId, out var n4) ? n4 : null; - currentBlock.Statements.Add(CreateToLoopCondStatement(ownerName4)); - return; - } - - WalkNode(nextNode, currentBlock, ctx, visited, loopbackTargetId); - } - - // ─── Statement Generation ──────────────────────────────────────────── - - private static FlowControlStatement CreateToLoopCondStatement(string? returnTo = null) - { - var stmt = new FlowControlStatement - { - ControlType = FlowControlType.ToLoopCond, - LineNumber = 1, - ToLoopCondReturnTo = returnTo - }; - stmt.RegenerateSourceCode(); - return stmt; - } - - /// - /// Generates a BlockStatement for the given node using Export Strategies, - /// with post-processing for PubVar assignments. - /// - private BlockStatement? GenerateStatement(BlueprintNode node, ReverseConversionContext ctx) - { - switch (node.NodeType) - { - case BlueprintNodeType.Entry: - case BlueprintNodeType.PluginTrigger: - return null; - case BlueprintNodeType.Break: - return new FlowControlStatement - { - ControlType = FlowControlType.Break, - SourceCode = "Break();", - LineNumber = 1 - }; - case BlueprintNodeType.BuiltinFunction when IsBreakNode(node): - return new FlowControlStatement - { - ControlType = FlowControlType.Break, - SourceCode = "Break();", - LineNumber = 1 - }; - case BlueprintNodeType.Get: - return GenerateGetStatementViaStrategy(node, ctx); - case BlueprintNodeType.BuiltinFunction when IsGetNode(node): - return GenerateGetStatementViaStrategy(node, ctx); - case BlueprintNodeType.Call: - { - if (node is not CallNode call) return null; - var callArgs = _exportHelper.GetInputArgs(call); - var funcRef = string.IsNullOrEmpty(call.PluginName) - ? call.FunctionName : $"{call.PluginName}.{call.FunctionName}"; - var sourceCode = $"{funcRef}({callArgs})"; - var callStmt = new ExpressionStatement - { - Expression = sourceCode, - SourceCode = sourceCode + ";", - LineNumber = 1 - }; - PostProcessCallReturn(node, callStmt, ctx); - return callStmt; - } - case BlueprintNodeType.CallHelper: - { - if (node is not CallHelperNode callHelper) return null; - var helperArgs = _exportHelper.GetInputArgs(callHelper); - var expression = $"{callHelper.HelperFunctionName}({helperArgs})"; - var helperStmt = new ExpressionStatement - { - Expression = expression, - SourceCode = expression + ";", - LineNumber = 1 - }; - PostProcessCallReturn(node, helperStmt, ctx); - return helperStmt; - } - case BlueprintNodeType.BuiltinFunction: - { - // Look up strategy by function name (BuiltinFunction nodes share one NodeType) - if (node is BuiltinFunctionNode bfNode - && _builtinFunctionStrategies.TryGetValue(bfNode.FunctionName, out var bfStrategy)) - return bfStrategy.ToStatement(node, _exportHelper); - return null; - } - default: - break; - } - - if (!_strategies.TryGetValue(node.NodeType, out var strategy)) - { - Log.Warning("[ExecutionFlowWalker] Unhandled node type: {NodeType}", node.NodeType); - return null; - } - - var stmt = strategy.ToStatement(node, _exportHelper); - if (stmt == null) return null; - - // Post-process: add PubVar prefix for Call/CallHelper with consumed Return - PostProcessCallReturn(node, stmt, ctx); - - return stmt; - } - - /// - /// Post-processes Call/CallHelper statements to add PubVar prefix when Return pin is consumed. - /// - private void PostProcessCallReturn(BlueprintNode node, BlockStatement stmt, ReverseConversionContext ctx) - { - if (node.NodeType is BlueprintNodeType.Call or BlueprintNodeType.CallHelper - && stmt is ExpressionStatement exprStmt) - { - var returnPin = node.OutputPins.FirstOrDefault(p => p.Name == Return); - bool hasReturn = returnPin != null && ctx.ConsumedOutputs.Contains((node.Id, Return)); - if (hasReturn) - { - var pubVar = NodeExportHelper.FindOutputPubVar(node, Return, ctx); - if (pubVar != null) - exprStmt.SourceCode = $"{pubVar} = {exprStmt.Expression};"; - } - } - } - - private BlockStatement? GenerateGetStatementViaStrategy(BlueprintNode node, ReverseConversionContext ctx) - { - if (!ctx.ConsumedOutputs.Contains((node.Id, Value))) - return null; - - var pubVar = NodeExportHelper.FindOutputPubVar(node, Value, ctx); - if (pubVar == null) return null; - - if (!_strategies.TryGetValue(BlueprintNodeType.Get, out var strategy)) - return null; - - var stmt = strategy.ToStatement(node, _exportHelper); - if (stmt is ExpressionStatement exprStmt) - { - exprStmt.SourceCode = $"{pubVar} = {exprStmt.Expression};"; - return exprStmt; - } - return stmt; - } - - // ─── Sub-graph Processing ──────────────────────────────────────────── - - private void ProcessSubGraphs(ReverseConversionContext ctx) - { - var processedTargets = new HashSet(); - var processedNodes = new HashSet(); - - while (ctx.PendingControlFlowNodes.Count > 0) - { - var pendingNodes = ctx.PendingControlFlowNodes.ToList(); - ctx.PendingControlFlowNodes.Clear(); - - foreach (var node in pendingNodes) - { - if (processedNodes.Contains(node.Id)) continue; - processedNodes.Add(node.Id); - - switch (node.NodeType) - { - case BlueprintNodeType.Branch: - case BlueprintNodeType.BuiltinFunction when IsBranchNode(node): - ProcessBranchSubGraphs(node, ctx, processedTargets); - break; - case BlueprintNodeType.Loop: - case BlueprintNodeType.BuiltinFunction when IsLoopNode(node): - ProcessLoopSubGraphs(node, ctx, processedTargets); - break; - } - } - } - - foreach (var kvp in ctx.ControlFlowMap) - { - var flow = kvp.Value; - if (ctx.BlockNameAssignments.TryGetValue(kvp.Key, out var assignments)) - { - flow.TrueBlockName = assignments.TrueBlockName; - flow.FalseBlockName = assignments.FalseBlockName; - flow.RegenerateSourceCode(); - } - } - } - - private void ProcessBranchSubGraphs(BlueprintNode branchNode, ReverseConversionContext ctx, - HashSet processedTargets) - { - var trueBlockName = string.Empty; - var falseBlockName = string.Empty; - var currentLoopback = ctx.CurrentLoopbackTargetId; - - foreach (var pinName in new[] { True, False }) - { - var pin = branchNode.OutputPins.FirstOrDefault(p => p.Name == pinName); - if (pin == null) continue; - - var conn = ctx.ExecConnections.FirstOrDefault(c => c.SourcePinId == pin.Id); - if (conn == null || processedTargets.Contains(conn.TargetNodeId)) continue; - - var targetNode = ctx.Blueprint.GetNodeById(conn.TargetNodeId); - if (targetNode == null) continue; - - var blockName = $"Block_{ctx.BlockCounter++}"; - var block = new BlockDefinition { Type = BlockType.NamedBlock, Name = blockName }; - - WalkNode(targetNode, block, ctx, new HashSet(), loopbackTargetId: currentLoopback); - - ctx.Script.NamedBlocks[blockName] = block; - processedTargets.Add(conn.TargetNodeId); - - if (pinName == True) trueBlockName = blockName; - else falseBlockName = blockName; - } - - ctx.BlockNameAssignments[branchNode.Id] = (trueBlockName, falseBlockName); - } - - private void ProcessLoopSubGraphs(BlueprintNode loopNode, ReverseConversionContext ctx, - HashSet processedTargets) - { - var loopBodyBlockName = string.Empty; - var loopEndBlockName = string.Empty; - - var loopBodyPin = loopNode.OutputPins.FirstOrDefault(p => p.Name == LoopBody); - if (loopBodyPin != null) - { - var conn = ctx.ExecConnections.FirstOrDefault(c => c.SourcePinId == loopBodyPin.Id); - if (conn != null && !processedTargets.Contains(conn.TargetNodeId)) - { - var targetNode = ctx.Blueprint.GetNodeById(conn.TargetNodeId); - if (targetNode != null) - { - loopBodyBlockName = $"Block_{ctx.BlockCounter++}"; - var block = new BlockDefinition { Type = BlockType.NamedBlock, Name = loopBodyBlockName }; - - ctx.CurrentLoopbackTargetId = loopNode.Id; - WalkNode(targetNode, block, ctx, new HashSet(), loopbackTargetId: loopNode.Id); - - ctx.Script.NamedBlocks[loopBodyBlockName] = block; - processedTargets.Add(conn.TargetNodeId); - ctx.LoopBodyBlocks[loopNode.Id] = block; - } - } - } - - var loopEndPin = loopNode.OutputPins.FirstOrDefault(p => p.Name == LoopEnd); - if (loopEndPin != null) - { - var conn = ctx.ExecConnections.FirstOrDefault(c => c.SourcePinId == loopEndPin.Id); - if (conn != null && !processedTargets.Contains(conn.TargetNodeId)) - { - var targetNode = ctx.Blueprint.GetNodeById(conn.TargetNodeId); - if (targetNode != null) - { - loopEndBlockName = $"Block_{ctx.BlockCounter++}"; - var block = new BlockDefinition { Type = BlockType.NamedBlock, Name = loopEndBlockName }; - - WalkNode(targetNode, block, ctx, new HashSet(), loopbackTargetId: null); - - ctx.Script.NamedBlocks[loopEndBlockName] = block; - processedTargets.Add(conn.TargetNodeId); - } - } - } - - ctx.BlockNameAssignments[loopNode.Id] = (loopBodyBlockName, loopEndBlockName); - } - - // ─── BlockScope ToLoopCond Helpers ─────────────────────────────────── - - private static (BlueprintNode? loopNode, List path) FollowExecChainToLoop( - BlueprintNode startNode, ReverseConversionContext ctx) - { - var path = new List(); - var current = startNode; - var visited = new HashSet(); - - while (current != null && !visited.Contains(current.Id)) - { - visited.Add(current.Id); - - if (IsLoopNode(current)) - return (current, path); - - var execOutPin = current.OutputPins.FirstOrDefault(p => p.Name == Exec); - if (execOutPin == null) break; - - var execConn = ctx.ExecConnections.FirstOrDefault(c => c.SourcePinId == execOutPin.Id); - if (execConn == null) break; - - if (!ctx.NodeById.TryGetValue(execConn.TargetNodeId, out var next)) break; - - path.Add(next); - current = next; - } - - return (null, path); - } - - private static string? FindScopeContainingNode(string nodeId, Contract.Workflow.Blueprint bp) - { - foreach (var scope in bp.BlockScopes) - { - if (scope.NodeIds.Contains(nodeId)) - return scope.Name; - } - return null; - } - - // ─── BuiltinFunction Node Type Helpers ───────────────────────────────── - - /// - /// Checks if a node represents the given logical type, accounting for both - /// legacy typed nodes (e.g. LoopNode) and registry-based BuiltinFunctionNode - /// with the equivalent function name. - /// - private static bool IsNodeType(BlueprintNode node, BlueprintNodeType type) => node.NodeType == type - || (node is BuiltinFunctionNode bfn && bfn.FunctionName == type.ToString()); - - private static bool IsBranchNode(BlueprintNode node) => - IsNodeType(node, BlueprintNodeType.Branch); - - private static bool IsLoopNode(BlueprintNode node) => - IsNodeType(node, BlueprintNodeType.Loop); - - private static bool IsBreakNode(BlueprintNode node) => - IsNodeType(node, BlueprintNodeType.Break); - - private static bool IsGetNode(BlueprintNode node) => - IsNodeType(node, BlueprintNodeType.Get); - - // ─── Loopback Edge Detection (BlockScopes fallback) ────────────────── - - /// - /// Detects exec connections that form loopback edges (from one scope's tail - /// to a scope containing a Loop node) and inserts ToLoopCond statements - /// when none was generated. This is a fallback for when ToLoopCond nodes - /// are absent or lost during Canvas round-trips. - /// - private static void DetectAndInsertLoopbackToLoopConds( - Contract.Workflow.Blueprint blueprint, ReverseConversionContext ctx) - { - // Build: LoopNodeId → ScopeName (which scope contains each Loop node) - var loopNodeToScope = new Dictionary(); - foreach (var scope in blueprint.BlockScopes) - { - foreach (var nodeId in scope.NodeIds) - { - if (ctx.LoopNodes.ContainsKey(nodeId)) - loopNodeToScope[nodeId] = scope.Name; - } - } - - if (loopNodeToScope.Count == 0) return; - - // Build: ScopeName → set of first node IDs - var scopeFirstNodes = new Dictionary>(); - foreach (var scope in blueprint.BlockScopes) - { - if (scope.NodeIds.Count == 0) continue; - scopeFirstNodes[scope.Name] = [scope.NodeIds[0]]; - } - - // Build: ScopeName → scope's block definition - var scopeToBlock = new Dictionary(); - if (ctx.Script.MainBlock != null) - scopeToBlock[BlockScriptWellKnown.Blocks.MainBlock] = ctx.Script.MainBlock; - foreach (var kvp in ctx.Script.NamedBlocks) - scopeToBlock[kvp.Key] = kvp.Value; - - // For each scope, check if its last node has an exec connection - // leading to a node inside a Loop-containing scope. - // Also check if the scope already ends with a ToLoopCond statement. - foreach (var scope in blueprint.BlockScopes) - { - if (scope.NodeIds.Count == 0) continue; - if (!scopeToBlock.TryGetValue(scope.Name, out var block)) continue; - - // Skip scopes that already end with ToLoopCond - if (block.Statements.LastOrDefault() is FlowControlStatement { ControlType: FlowControlType.ToLoopCond }) - continue; - - // Find the last node in this scope that has an exec output connection - string? lastNodeId = null; - for (int i = scope.NodeIds.Count - 1; i >= 0; i--) - { - var nodeId = scope.NodeIds[i]; - if (!ctx.NodeById.TryGetValue(nodeId, out var node)) continue; - var execOut = node.OutputPins.FirstOrDefault(p => p.Name == Exec); - if (execOut == null) continue; - var execConn = ctx.ExecConnections.FirstOrDefault(c => c.SourcePinId == execOut.Id); - if (execConn != null) - { - lastNodeId = nodeId; - break; - } - } - - if (lastNodeId == null) continue; - if (!ctx.NodeById.TryGetValue(lastNodeId, out var lastNode)) continue; - - var lastExecOut = lastNode.OutputPins.First(p => p.Name == Exec); - var lastExecConn = ctx.ExecConnections.FirstOrDefault(c => c.SourcePinId == lastExecOut.Id); - if (lastExecConn == null) continue; - - var targetNode = blueprint.GetNodeById(lastExecConn.TargetNodeId); - if (targetNode == null) continue; - - // Check: does the target node belong to a scope that contains a Loop? - var targetScopeName = FindScopeContainingNode(targetNode.Id, blueprint); - if (targetScopeName == null) continue; - - // Check: does that scope contain a Loop node? - var targetScope = blueprint.BlockScopes.FirstOrDefault(s => s.Name == targetScopeName); - if (targetScope == null) continue; - - bool targetScopeContainsLoop = targetScope.NodeIds - .Any(nid => ctx.LoopNodes.ContainsKey(nid)); - if (!targetScopeContainsLoop) continue; - - // Skip if the target scope is the same as the source scope - if (targetScopeName == scope.Name) continue; - - // Found a loopback edge! Insert ToLoopCond pointing to the Loop's scope. - block.Statements.Add(CreateToLoopCondStatement(targetScopeName)); - Log.Debug("[ExecutionFlowWalker] Inserted fallback ToLoopCond(\"{Target}\") in scope \"{Scope}\"", - targetScopeName, scope.Name); - } - } -} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeExportHelper.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeExportHelper.cs index 8802a2de..658704a8 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeExportHelper.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeExportHelper.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; using System.Linq; using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.Blueprint.ReversePipeline; +using KitX.Core.Workflow.Blueprint.Pipeline; using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Pins; @@ -18,13 +18,13 @@ internal class NodeExportHelper : INodeExportHelper public Contract.Workflow.Blueprint Blueprint { get; private set; } = null!; /// Sets the current blueprint and context for resolution - public void SetContext(Contract.Workflow.Blueprint blueprint, ReverseConversionContext? ctx) + public void SetContext(Contract.Workflow.Blueprint blueprint, ConversionContext? ctx) { Blueprint = blueprint; _currentCtx = ctx; } - private ReverseConversionContext? _currentCtx; + private ConversionContext? _currentCtx; /// public string GetInputValue(BlueprintNode node, string pinName) @@ -57,7 +57,7 @@ public string GetInputArgs(BlueprintNode node) /// /// Resolves an input pin's value using the pre-built InputDataMap (for topology path). /// - public string ResolveInputValue(BlueprintNode node, string pinName, ReverseConversionContext ctx) + public string ResolveInputValue(BlueprintNode node, string pinName, ConversionContext ctx) { var pin = node.InputPins.FirstOrDefault(p => p.Name == pinName); if (pin == null) return string.Empty; @@ -77,7 +77,7 @@ public string ResolveInputValue(BlueprintNode node, string pinName, ReverseConve /// /// Finds the PubVar name assigned to a node's output pin. /// - public static string? FindOutputPubVar(BlueprintNode node, string pinName, ReverseConversionContext ctx) + public static string? FindOutputPubVar(BlueprintNode node, string pinName, ConversionContext ctx) { var pin = node.OutputPins.FirstOrDefault(p => p.Name == pinName); if (pin == null) return null; @@ -87,12 +87,15 @@ public string ResolveInputValue(BlueprintNode node, string pinName, ReverseConve /// /// Formats a literal value for BlockScript output — wraps strings in quotes, - /// passes through numbers, booleans, and already-quoted values. + /// passes through numbers, booleans, char literals, and already-quoted values. + /// Uses Roslyn to validate character literals rather than string-pattern heuristics. /// - public static string FormatLiteralValue(string value, ReverseConversionContext? ctx = null) + public static string FormatLiteralValue(string value, ConversionContext? ctx = null) { - if (string.IsNullOrEmpty(value)) return value; + if (value == null) return string.Empty; + if (value.Length == 0) return "\"\""; // empty string literal if (value.StartsWith("\"")) return value; + if (ExprUtils.IsCharacterLiteral(value)) return value; // char literal — pass through if (ctx != null && ctx.AllPubVars.Contains(value)) return value; if (int.TryParse(value, out _) || double.TryParse(value, out _)) return value; if (value == "true" || value == "false") return value; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/DataEdgeBuilder.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/DataEdgeBuilder.cs index 8531b4ba..2cb50b43 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/DataEdgeBuilder.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/DataEdgeBuilder.cs @@ -102,6 +102,13 @@ private void ProcessArgument(string arg, BlueprintNode targetNode, string target return; } + // Character literal → DefaultValue (pass through as-is, e.g. '\0') + if (ExprUtils.IsCharacterLiteral(trimmed)) + { + SetDefaultValue(targetNode, targetPinName, trimmed); + return; + } + // Numeric literal → DefaultValue if (int.TryParse(trimmed, out _) || double.TryParse(trimmed, out _)) { diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ExprUtils.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ExprUtils.cs index b86d4a4c..3640738c 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ExprUtils.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ExprUtils.cs @@ -159,4 +159,42 @@ public static bool IsNumericLiteral(ExpressionSyntax expr) /// Gets the value of any literal expression (string, int, double, bool, null). public static object? GetLiteralValue(LiteralExpressionSyntax literal) => literal.Token.Value; + + /// + /// Classifies a literal expression into a type tag used by the pipeline. + /// Uses Roslyn's SyntaxKind for reliable classification — no string-pattern guessing. + /// Returns: "string", "char", "number", "bool", or "unknown". + /// + public static string ClassifyLiteral(LiteralExpressionSyntax literal) + { + var kind = literal.Token.Kind(); + if (kind == SyntaxKind.StringLiteralToken) + return "string"; + if (kind == SyntaxKind.CharacterLiteralToken) + return "char"; + if (kind == SyntaxKind.NumericLiteralToken) + return "number"; + if (kind is SyntaxKind.TrueLiteralExpression or SyntaxKind.FalseLiteralExpression + || kind is SyntaxKind.TrueKeyword or SyntaxKind.FalseKeyword) + return "bool"; + if (kind == SyntaxKind.NullKeyword) + return "null"; + return "unknown"; + } + + /// + /// Determines whether a value string represents a C# character literal (e.g. '\0', 'a', '\n'). + /// Uses Roslyn parsing for reliable detection — avoids string-pattern heuristics. + /// Returns true only when the expression parses as a CharacterLiteralExpression. + /// + public static bool IsCharacterLiteral(string value) + { + if (string.IsNullOrEmpty(value)) return false; + // Fast pre-check: C# char literals always start and end with single quote + if (value.Length < 3 || value[0] != '\'' || value[^1] != '\'') return false; + // Validate with Roslyn + var expr = ParseExpression(value); + return expr is LiteralExpressionSyntax lit + && lit.Token.IsKind(SyntaxKind.CharacterLiteralToken); + } } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ReversePipeline/BlockScriptAssembler.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ReversePipeline/BlockScriptAssembler.cs deleted file mode 100644 index b4080c56..00000000 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ReversePipeline/BlockScriptAssembler.cs +++ /dev/null @@ -1,84 +0,0 @@ -using System.Linq; -using System.Text; -using KitX.Core.Contract.Workflow; -using Serilog; - -using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Blocks; - -namespace KitX.Core.Workflow.Blueprint.ReversePipeline; - -/// -/// Phase 4 of reverse conversion: assembles the final BlockScript source code -/// from the constructed block definitions. -/// -internal class BlockScriptAssembler -{ - public void Assemble(ReverseConversionContext ctx) - { - var sb = new StringBuilder(); - - // #ConstBlock — no 'const' keyword in BlockScript ConstBlock - // Includes both ConstNode (with initial values) and VariableNode (without initial values) - var constNodes = ctx.Blueprint.Nodes.OfType().ToList(); - var varNodes = ctx.Blueprint.Nodes.OfType().ToList(); - - if (constNodes.Count > 0 || varNodes.Count > 0) - { - sb.AppendLine(MarkerConstBlock); - foreach (var cn in constNodes) - { - if (string.IsNullOrEmpty(cn.ConstValue)) - { - sb.AppendLine($"{cn.ConstType} {cn.ConstName};"); - } - else - { - var value = cn.ConstType == "string" ? $"\"{cn.ConstValue}\"" : cn.ConstValue; - sb.AppendLine($"{cn.ConstType} {cn.ConstName} = {value};"); - } - } - foreach (var vn in varNodes) - { - sb.AppendLine($"{vn.VarType} {vn.VarName};"); - } - sb.AppendLine(); - } - - // #PubVarBlock — use 'dynamic' for late-bound type resolution - if (ctx.AllPubVars.Count > 0) - { - sb.AppendLine(MarkerPubVarBlock); - foreach (var pv in ctx.AllPubVars) - { - sb.AppendLine($"dynamic {pv};"); - } - sb.AppendLine(); - } - - // #MainBlock - if (ctx.Script.MainBlock != null) - { - sb.AppendLine(MarkerMainBlock); - foreach (var stmt in ctx.Script.MainBlock.Statements) - { - sb.AppendLine(stmt.SourceCode); - } - sb.AppendLine(); - } - - // Named blocks - foreach (var kvp in ctx.Script.NamedBlocks) - { - sb.AppendLine($"{MarkerBlockPrefix}{kvp.Key}"); - foreach (var stmt in kvp.Value.Statements) - { - sb.AppendLine(stmt.SourceCode); - } - sb.AppendLine(); - } - - ctx.Script.SourceCode = sb.ToString().TrimEnd(); - - Log.Debug("[BlueprintToScript] Phase 4 done. Source code length: {Len}", ctx.Script.SourceCode.Length); - } -} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ReversePipeline/BlueprintAnalyzer.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ReversePipeline/BlueprintAnalyzer.cs deleted file mode 100644 index 2c31f93c..00000000 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ReversePipeline/BlueprintAnalyzer.cs +++ /dev/null @@ -1,113 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.Blueprint.Pipeline; -using Serilog; - -namespace KitX.Core.Workflow.Blueprint.ReversePipeline; - -/// -/// Phase 1 of reverse conversion: analyzes Blueprint to build data indexes, -/// classify connections, and auto-assign PubVar names where missing. -/// -internal class BlueprintAnalyzer -{ - public void Analyze(ReverseConversionContext ctx) - { - var bp = ctx.Blueprint; - - // Build node lookup - foreach (var node in bp.Nodes) - ctx.NodeById[node.Id] = node; - - // Classify connections and build data indexes - foreach (var conn in bp.Connections) - { - var sourceNode = bp.GetNodeById(conn.SourceNodeId); - if (sourceNode == null) continue; - - var sourcePin = sourceNode.GetPinById(conn.SourcePinId); - if (sourcePin == null) continue; - - if (sourcePin.Type == PinType.Execution) - { - ctx.ExecConnections.Add(conn); - } - else - { - ctx.DataConnections.Add(conn); - - // Build input data map - var targetNode = bp.GetNodeById(conn.TargetNodeId); - if (targetNode != null) - { - var targetPin = targetNode.GetPinById(conn.TargetPinId); - if (targetPin != null) - { - ctx.InputDataMap[(conn.TargetNodeId, targetPin.Name)] = new DataEdgeInfo - { - SourceNode = sourceNode, - SourcePinName = sourcePin.Name, - SourcePin = sourcePin, - PubVarName = conn.PubVarName, - Connection = conn - }; - - // Track consumed outputs - ctx.ConsumedOutputs.Add((conn.SourceNodeId, sourcePin.Name)); - } - } - } - } - - // Assign PubVar names where needed but missing - foreach (var conn in ctx.DataConnections) - { - if (!string.IsNullOrEmpty(conn.PubVarName)) continue; - - var sourceNode = bp.GetNodeById(conn.SourceNodeId); - if (sourceNode == null) continue; - - // ConstNode references don't need PubVar - if (sourceNode.NodeType == BlueprintNodeType.Const) continue; - - // GetNode, BuiltinFunctionNode(Get), CallNode, CallHelperNode outputs need PubVar if consumed downstream - if (sourceNode.NodeType is BlueprintNodeType.Get or BlueprintNodeType.Call - or BlueprintNodeType.CallHelper - || (sourceNode is BuiltinFunctionNode bfn && bfn.FunctionName == "Get")) - { - var sourcePin = sourceNode.GetPinById(conn.SourcePinId); - if (sourcePin == null) continue; - - var pubVar = ExprUtils.GeneratePubVarName(ctx.PubVarCounter++); - conn.PubVarName = pubVar; - ctx.AutoPubVars.Add(pubVar); - - // Update the InputDataMap entry - var targetNode = bp.GetNodeById(conn.TargetNodeId); - if (targetNode != null) - { - var targetPin = targetNode.GetPinById(conn.TargetPinId); - if (targetPin != null && ctx.InputDataMap.TryGetValue( - (conn.TargetNodeId, targetPin.Name), out var info)) - { - info.PubVarName = pubVar; - } - } - - Log.Debug("[BlueprintToScript] Auto-assigned PubVar {PubVar} for {NodeType}.{Pin}", - pubVar, sourceNode.NodeType, sourcePin.Name); - } - } - - // Collect all PubVar names - foreach (var conn in ctx.DataConnections) - { - if (!string.IsNullOrEmpty(conn.PubVarName) && !ctx.AllPubVars.Contains(conn.PubVarName)) - ctx.AllPubVars.Add(conn.PubVarName); - } - - Log.Debug("[BlueprintToScript] Phase 1 done: {Nodes} nodes, {Exec} exec conns, {Data} data conns, {PubVars} pubvars", - ctx.NodeById.Count, ctx.ExecConnections.Count, ctx.DataConnections.Count, ctx.AllPubVars.Count); - } -} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ReversePipeline/ConditionDuplicator.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ReversePipeline/ConditionDuplicator.cs deleted file mode 100644 index 9712d2f4..00000000 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ReversePipeline/ConditionDuplicator.cs +++ /dev/null @@ -1,128 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using KitX.Core.Contract.Workflow; - -using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Pins; - -namespace KitX.Core.Workflow.Blueprint.ReversePipeline; - -/// -/// Phase 3 of reverse conversion: duplicates Loop condition evaluation statements -/// before each ToLoopCond in loop body blocks. -/// -internal class ConditionDuplicator -{ - public void Duplicate(ReverseConversionContext ctx) - { - foreach (var kvp in ctx.LoopBodyBlocks) - { - var loopNodeId = kvp.Key; - var loopBodyBlock = kvp.Value; - - if (!ctx.LoopNodes.TryGetValue(loopNodeId, out var loopNode)) continue; - - var flow = ctx.ControlFlowMap.TryGetValue(loopNodeId, out var f) ? f : null; - if (flow == null) continue; - - var conditionExpr = flow.ConditionExpression; - if (string.IsNullOrEmpty(conditionExpr)) continue; - - var condStmts = FindConditionStatements(loopNode, ctx); - if (condStmts.Count == 0) continue; - - // Insert duplicates before each ToLoopCond in the loop body block - var insertions = new List<(int index, List stmts)>(); - - for (int i = 0; i < loopBodyBlock.Statements.Count; i++) - { - var stmt = loopBodyBlock.Statements[i]; - if (stmt is FlowControlStatement flowStmt - && flowStmt.ControlType == FlowControlType.ToLoopCond) - { - var dupStmts = condStmts.Select(CloneStatement).ToList(); - insertions.Add((i, dupStmts.Cast().ToList())); - } - } - - // Apply insertions in reverse order to preserve indices - foreach (var (index, stmts) in insertions.OrderByDescending(x => x.index)) - loopBodyBlock.Statements.InsertRange(index, stmts); - } - } - - /// - /// Finds the condition evaluation statements for a Loop node. - /// These are the Get/CallHelper statements that compute the condition value. - /// - private List FindConditionStatements(BlueprintNode loopNode, - ReverseConversionContext ctx) - { - var result = new List(); - - var conditionPin = loopNode.InputPins.FirstOrDefault(p => p.Name == Condition); - if (conditionPin == null) return result; - - if (!ctx.InputDataMap.TryGetValue((loopNode.Id, Condition), out var condInfo)) - return result; - - var chainPubVars = new HashSet(); - CollectConditionChain(condInfo, ctx, chainPubVars); - - if (chainPubVars.Count == 0) return result; - - if (ctx.Script.MainBlock == null) return result; - - var loopFlowStmt = ctx.ControlFlowMap.TryGetValue(loopNode.Id, out var lf) ? lf : null; - if (loopFlowStmt == null) return result; - - var loopIndex = ctx.Script.MainBlock.Statements.IndexOf(loopFlowStmt); - if (loopIndex < 0) return result; - - for (int i = loopIndex - 1; i >= 0; i--) - { - var stmt = ctx.Script.MainBlock.Statements[i]; - if (stmt is ExpressionStatement exprStmt) - { - foreach (var pv in chainPubVars) - { - if (exprStmt.SourceCode.StartsWith($"{pv} = ")) - { - result.Insert(0, exprStmt); - break; - } - } - } - } - - return result; - } - - /// - /// Recursively collects all PubVar names in the data chain leading to a condition. - /// - private static void CollectConditionChain(DataEdgeInfo info, ReverseConversionContext ctx, - HashSet pubVars) - { - if (!string.IsNullOrEmpty(info.PubVarName)) - pubVars.Add(info.PubVarName); - - if (info.SourceNode.NodeType is BlueprintNodeType.CallHelper or BlueprintNodeType.Call) - { - foreach (var pin in info.SourceNode.InputPins) - { - if (pin.Name == Exec) continue; - if (ctx.InputDataMap.TryGetValue((info.SourceNode.Id, pin.Name), out var argInfo)) - { - CollectConditionChain(argInfo, ctx, pubVars); - } - } - } - } - - private static ExpressionStatement CloneStatement(ExpressionStatement source) => new() - { - Expression = source.Expression, - SourceCode = source.SourceCode, - LineNumber = source.LineNumber - }; -} From 207d6c3341c9f7f4e4f4ca6c028bcf3c2a7307c3 Mon Sep 17 00:00:00 2001 From: StarInk Date: Tue, 14 Apr 2026 16:21:56 +0200 Subject: [PATCH 055/110] =?UTF-8?q?=F0=9F=92=BE=20Feat(Compiler):=20?= =?UTF-8?q?=E7=9B=B4=E6=8E=A5=E5=B0=86=E8=84=9A=E6=9C=AC=E8=BD=AC=E6=8D=A2?= =?UTF-8?q?=E4=B8=BA=E5=8F=AF=E7=BC=96=E8=AF=91C#=E8=84=9A=E6=9C=AC?= =?UTF-8?q?=E5=B9=B6=E7=9B=B4=E6=8E=A5=E9=A2=84=E7=BC=96=E8=AF=91=EF=BC=8C?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E6=8F=90=E9=80=9F10000x(=E4=BD=BF=E7=94=A8Br?= =?UTF-8?q?ainFuck=E7=BC=96=E8=AF=91=E5=99=A8=E8=84=9A=E6=9C=AC=E6=B5=8B?= =?UTF-8?q?=E8=AF=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../KitX.Core.BluePrint.Test/Program.cs | 108 ++ .../BlockScriptExecutionGlobals.cs | 26 +- .../BlockScripting/BlockScriptExecutor.cs | 43 + .../BlockScripting/ICompiledBlockScript.cs | 21 + .../BlockScripting/ScriptAssemblyCompiler.cs | 1186 +++++++++++++++++ .../Blueprint/Pipeline/ScriptFormatter.cs | 5 +- 6 files changed, 1387 insertions(+), 2 deletions(-) create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ICompiledBlockScript.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptAssemblyCompiler.cs diff --git a/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs b/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs index e7f87214..a8b65315 100644 --- a/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs +++ b/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs @@ -156,6 +156,14 @@ public static void Main(string[] args) Console.WriteLine("└──────────────────────────────────────────┘\n"); RunExecutionTest(converter, reverseConverter, parser, sp, GetRawNestedScript(), helpers, "Test K"); } + + if (ShouldRunTest("L")) + { + Console.WriteLine("\n┌──────────────────────────────────────────┐"); + Console.WriteLine("│ Test L: Assembly Compilation │"); + Console.WriteLine("└──────────────────────────────────────────┘\n"); + RunAssemblyCompilationTest(reverseConverter, parser, sp); + } } private static void ParseArgs(string[] args) @@ -366,6 +374,106 @@ private static void RunExecutionTest( } } + // Test L: Assembly Compilation + // ────────────────────────────────────────────── + private static void RunAssemblyCompilationTest( + IBlueprintToBlockScriptConverter reverseConverter, + IBlockScriptParser parser, + System.IServiceProvider sp) + { + try + { + var sourceCode = @"#ConstBlock +int count = 0; +int max = 3; + +#MainBlock +Set(""count"", 0); +NextBlock = ""LoopBlock""; + +#Block LoopBlock +NextBlock = Loop(HelperFuncCompare(""BLT"", Get(""count""), max), ""PrintBlock"", ""EndBlock""); + +#Block PrintBlock +Print(Get(""count"")); +Set(""count"", HelperFuncAdd(Get(""count""), 1)); +NextBlock = ToLoopCond(""LoopBlock""); + +#Block EndBlock +Print(""Done""); +"; + + var parseResult = parser.Parse(sourceCode); + if (!parseResult.IsSuccess || parseResult.Script == null) + { + Console.WriteLine("[Test L] FAILED: Parse error: " + parseResult.ErrorMessage); + return; + } + + parseResult.Script.HelperFunctions = GetExecutionHelpers(); + + // Test 1: Direct ScriptAssemblyCompiler test + var compiler = new ScriptAssemblyCompiler(); + Console.WriteLine("[Test L] Compiling..."); + var compiled = compiler.CompileScript(parseResult.Script); + Console.WriteLine($"[Test L] Assembly compilation: {(compiled != null ? "SUCCESS" : "FAILED (null)")}"); + + if (compiled != null) + { + // Test 2: Execute via compiled assembly + var output = new List(); + var globals = new BlockScriptExecutionGlobals( + new BlockScopeManager(), output); + globals.ResetRunState(); + + Console.WriteLine("[Test L] About to call Run()..."); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + try + { + compiled.Run(globals, cts.Token); + Console.WriteLine($"[Test L] Assembly execution: SUCCESS"); + Console.WriteLine($"[Test L] ExecutedBlockCount: {globals.ExecutedBlockCount}"); + Console.WriteLine($"[Test L] Output: [{string.Join(", ", output)}]"); + } + catch (OperationCanceledException) + { + Console.WriteLine("[Test L] Assembly execution: TIMEOUT (infinite loop?)"); + } + catch (Exception ex) + { + Console.WriteLine($"[Test L] Assembly execution: EXCEPTION - {ex.GetType().Name}: {ex.Message}"); + if (ex.InnerException != null) + Console.WriteLine($" Inner: {ex.InnerException.GetType().Name}: {ex.InnerException.Message}"); + } + } + + // Test 3: Compare via IBlockScriptExecutor (should use assembly path) + var executor = sp.GetRequiredService(); + using var cts2 = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var result = executor.ExecuteAsync(parseResult.Script, cancellationToken: cts2.Token) + .GetAwaiter().GetResult(); + Console.WriteLine($"[Test L] IBlockScriptExecutor: IsSuccess={result.IsSuccess}, BlockCount={result.ExecutedBlockCount}"); + Console.WriteLine($"[Test L] Executor output ({result.Output.Count} lines): [{string.Join(", ", result.Output)}]"); + + // Verify output matches expected: 0, 1, 2, Done + var expected = new List { "0", "1", "2", "Done" }; + bool match = result.IsSuccess && result.Output.Count == expected.Count; + if (match) + { + for (int i = 0; i < expected.Count; i++) + { + if (result.Output[i] != expected[i]) { match = false; break; } + } + } + + Console.WriteLine($"[Test L] {(match ? "PASS - Output correct!" : (result.IsSuccess ? "DIFF" : "FAILED"))}"); + } + catch (Exception ex) + { + Console.WriteLine($"[Test L] FAILED: {ex.Message}\n {ex.StackTrace}"); + } + } + /// /// Helper functions with actual execution bodies (for execution test). /// diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutionGlobals.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutionGlobals.cs index 30b436fa..42e6cf60 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutionGlobals.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutionGlobals.cs @@ -24,6 +24,12 @@ public class BlockScriptExecutionGlobals /// public string? NextBlock { get; set; } = null; + /// + /// Number of blocks executed so far in the current run. + /// Incremented at the start of each block's switch case in the compiled assembly path. + /// + public int ExecutedBlockCount { get; set; } + /// /// Creates script globals /// @@ -55,6 +61,20 @@ public dynamic Get(string name) return _scopeManager.ResolveVariable(name)!; } + /// + /// Gets a variable value typed as . + /// Used by the compiled assembly path to avoid dynamic binding overhead. + /// Performs standard unboxing/conversion, no Microsoft.CSharp.RuntimeBinder needed. + /// + public T? Get(string name) + { + if (name == "NextBlock") + return (T?)(object?)NextBlock; + if (_variables.TryGetValue(name, out var value)) + return (T?)value; + return (T?)_scopeManager.ResolveVariable(name); + } + /// /// Sets a variable value /// @@ -140,7 +160,11 @@ public void Pause(int milliseconds) /// /// Resets run-level state (called when execution starts from Entry node) /// - public void ResetRunState() => _flipCounter = 0; + public void ResetRunState() + { + _flipCounter = 0; + ExecutedBlockCount = 0; + } /// /// Flip — alternating control flow. Routes to outputA on odd calls, outputB on even calls. diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutor.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutor.cs index f3e5cc92..d8c5a792 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutor.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutor.cs @@ -45,6 +45,9 @@ public class BlockScriptExecutor : IBlockScriptExecutor private Dictionary? _blockCodeCache; private HashSet? _predeclaredVariables; + // Full-script assembly compilation + private readonly ScriptAssemblyCompiler _assemblyCompiler = new(); + /// /// Creates a new block script executor /// @@ -91,6 +94,46 @@ public async Task ExecuteAsync( try { + // ── Fast path: Full-script assembly compilation ── + // Try to compile the entire script into a .NET assembly and run it directly. + // This eliminates all per-block CSharpScript overhead (ContinueWithAsync). + // On failure, falls back to the existing CSharpScript execution path. + try + { + var compiled = _assemblyCompiler.CompileScript(script); + if (compiled != null) + { + Log.Debug("[BlockScriptExecutor] Using assembly-compiled execution path"); + _globals = new BlockScriptExecutionGlobals(_scopeManager, _output, _pluginManager); + _globals.ResetRunState(); + _scopeManager.InitializeGlobalScope(script); + + // Import parameters + if (parameters != null) + { + foreach (var p in parameters) + _globals.Set(p.Key, p.Value); + } + + compiled.Run(_globals, cancellationToken); + + _stopwatch.Stop(); + return new BlockScriptExecutionResult + { + IsSuccess = true, + ExecutedBlockCount = _globals.ExecutedBlockCount, + ExecutionTimeMs = _stopwatch.ElapsedMilliseconds, + Output = _output + }; + } + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + Log.Warning(ex, "[BlockScriptExecutor] Assembly compilation failed, falling back to CSharpScript"); + } + + // ── Fallback path: CSharpScript execution (per-block) ── // Store script reference for use in EvaluateExpressionAsync _currentScript = script; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ICompiledBlockScript.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ICompiledBlockScript.cs new file mode 100644 index 00000000..0194bdc6 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ICompiledBlockScript.cs @@ -0,0 +1,21 @@ +using System.Threading; +using KitX.Core.Contract.Workflow; + +namespace KitX.Core.Workflow.BlockScripting; + +/// +/// Represents a BlockScript compiled into a .NET assembly via Roslyn CSharpCompilation. +/// The Run method executes the entire script in a single JIT-compiled method call, +/// eliminating all per-block CSharpScript overhead. +/// +public interface ICompiledBlockScript +{ + /// + /// Executes the compiled script using the provided globals and cancellation token. + /// The method contains a while(true) + switch(NextBlock) dispatcher that + /// runs all blocks until the script naturally terminates or is cancelled. + /// + /// Execution globals providing built-in functions and variable storage. + /// Token for cooperative cancellation. + void Run(BlockScriptExecutionGlobals globals, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptAssemblyCompiler.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptAssemblyCompiler.cs new file mode 100644 index 00000000..266e20b9 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptAssemblyCompiler.cs @@ -0,0 +1,1186 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.Loader; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using KitX.Core.Contract.Workflow; +using KitX.Core.Workflow.Blueprint.Pipeline; +using Serilog; + +using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; + +namespace KitX.Core.Workflow.BlockScripting; + +/// +/// Compiles an entire into a .NET assembly using Roslyn +/// . The generated assembly contains a single class +/// implementing with a Run method that +/// executes all blocks via a while(true) + switch(G.NextBlock) dispatcher. +/// +/// Unified type conversion architecture ("超级电容"): Before code generation, +/// the script is formatted via to expand nested calls into +/// PubVar assignments. Type inference then determines each PubVar's target type from +/// downstream consumer signatures. ConvertTo<T> is inserted at data sources +/// (e.g., Get returns) where the source is object but the consumer needs +/// a specific type. This eliminates the need for __HelperFuncXxx object-param +/// wrappers — helper functions are called directly with typed PubVars. +/// +/// Compilation results are cached by script hash. Loaded assemblies use +/// for unloadability. +/// +internal class ScriptAssemblyCompiler +{ + /// + /// Well-known identifiers that must be prefixed with G. in the compiled assembly. + /// + private static readonly HashSet GlobalsIdentifiers = new(StringComparer.Ordinal) + { + "Print", "Set", "Get", "Branch", "Loop", "ToLoopCond", + "Flip", "PluginCall", "Pause", "NextBlock" + }; + + /// + /// Auto-discovered builtin function registry used by the + /// to correctly classify and expand function calls during the formatting phase. + /// + private static readonly BuiltinFunctionRegistry FunctionRegistry = + BuiltinFunctionRegistry.Discover(typeof(ScriptAssemblyCompiler).Assembly); + + /// + /// Cached compiled script entries, keyed by computed script hash. + /// + private readonly Dictionary _cache = new(); + + // ────────────────────────────────────────────── + // Public API + // ────────────────────────────────────────────── + + /// + /// Compiles a into an . + /// Returns null if compilation fails (caller should fall back to CSharpScript). + /// + public ICompiledBlockScript? CompileScript(BlockScript script) + { + var hash = ComputeScriptHash(script); + + // Check cache + if (_cache.TryGetValue(hash, out var entry) && entry.IsAlive) + { + Log.Debug("[ScriptAssemblyCompiler] Cache hit for script hash '{Hash}'", hash); + return entry.Instance; + } + + try + { + // Phase 1: Format script + infer PubVar types + var (formattedScript, pubVarTypes) = FormatAndInferTypes(script); + + // Phase 2: Generate CompilationUnitSyntax using Roslyn SyntaxFactory + var compilationUnit = GenerateCompilationUnit(script, formattedScript, pubVarTypes, hash); + + // Phase 3: Compile via CSharpCompilation + var assembly = CompileToAssembly(compilationUnit, hash); + if (assembly == null) + return null; + + // Phase 4: Load into collectible ALContext and instantiate + var alc = new CollectibleAssemblyLoadContext(hash); + var loadedAssembly = alc.LoadFromStream(assembly); + var scriptType = loadedAssembly.GetType( + $"KitX.Core.Workflow.BlockScripting.Generated.CompiledScript_{hash}"); + if (scriptType == null) + { + Log.Warning("[ScriptAssemblyCompiler] Compiled type not found in assembly"); + alc.Unload(); + return null; + } + + var instance = (ICompiledBlockScript)Activator.CreateInstance(scriptType)!; + + // Cache + _cache[hash] = new CompiledScriptEntry(instance, alc); + + Log.Debug("[ScriptAssemblyCompiler] Successfully compiled and cached script hash '{Hash}'", hash); + return instance; + } + catch (Exception ex) + { + Log.Warning(ex, "[ScriptAssemblyCompiler] Compilation failed, returning null for fallback"); + return null; + } + } + + /// + /// Clears the compilation cache and unloads all cached assemblies. + /// + public void ClearCache() + { + foreach (var entry in _cache.Values) + entry.Unload(); + _cache.Clear(); + } + + // ────────────────────────────────────────────── + // Phase 1: Format + Type Inference + // ────────────────────────────────────────────── + + /// + /// Formats the script using (expands nested calls into + /// PubVar assignments), then infers PubVar types from downstream consumer signatures. + /// + private (FormattedBlockScript formatted, Dictionary pubVarTypes) FormatAndInferTypes( + BlockScript script) + { + var context = new PipelineContext { Script = script }; + + // Pre-populate PubVarNames with PubVarBlock variable names so the ScriptFormatter + // recognizes existing PubVars and doesn't renumber them. Without this, BS→BP→BS + // round-trip scripts lose their PubVar assignments and produce broken FormattedStatements. + if (script.PubVarBlock != null) + { + foreach (var variable in script.PubVarBlock.Variables) + { + if (!context.PubVarNames.Contains(variable.Name)) + context.PubVarNames.Add(variable.Name); + } + } + + var formatter = new ScriptFormatter(script.HelperFunctions ?? [], FunctionRegistry); + var formattedScript = formatter.Format(script, context); + + // Debug: dump formatted script structure + Log.Debug("[ScriptAssemblyCompiler] Formatted script: {BlockCount} blocks, MainBlock={Main}", + formattedScript.Blocks.Count, formattedScript.MainBlockName); + foreach (var block in formattedScript.Blocks) + { + Log.Debug("[ScriptAssemblyCompiler] Block '{Name}' → NextBlock={Next}, Statements={Count}", + block.Name, block.NextBlockName, block.Statements.Count); + foreach (var stmt in block.Statements) + Log.Debug("[ScriptAssemblyCompiler] Kind={Kind} PubVar={PubVar} Fn={Fn} Args=[{Args}] CondPubVar={Cond} SetVar={Set} GetVar={Get}", + stmt.Kind, stmt.PubVarTarget, stmt.FunctionName, + string.Join(", ", stmt.Arguments), stmt.ConditionPubVar, stmt.SetVarName, stmt.GetVarName); + } + + var pubVarTypes = InferPubVarTypes(formattedScript, script.HelperFunctions, context); + return (formattedScript, pubVarTypes); + } + + /// + /// Infers PubVar types by analyzing downstream consumer signatures. + /// Two-pass algorithm: + /// + /// First pass: determine SOURCE type for each PubVar (Get → object, HelperFunc → ReturnType) + /// Second pass: determine DEMANDED type from consumers (Branch → bool, HelperFunc param → param type) + /// + /// ConvertTo<T> is needed when SOURCE is object but DEMANDED is a specific type. + /// + private Dictionary InferPubVarTypes( + FormattedBlockScript formattedScript, + List? helperFunctions, + PipelineContext context) + { + var pubVarTypes = new Dictionary(StringComparer.Ordinal); + var helperMap = (helperFunctions ?? []).ToDictionary(h => h.Name, h => h, StringComparer.Ordinal); + + // Initialize all PubVars to "object" (from PipelineContext.PubVarNames) + foreach (var name in context.PubVarNames) + pubVarTypes[name] = "object"; + + // ── First pass: SOURCE types ── + foreach (var block in formattedScript.Blocks) + { + foreach (var stmt in block.Statements) + { + if (stmt.PubVarTarget == null) continue; + + if (stmt.FunctionName == "Get") + { + // Get("var") always returns object + pubVarTypes[stmt.PubVarTarget] = "object"; + } + else if (helperMap.TryGetValue(stmt.FunctionName ?? "", out var helper)) + { + // Helper function call → ReturnType is known + pubVarTypes[stmt.PubVarTarget] = helper.ReturnType; + } + // Plugin calls: default to "object" (already set above) + // TODO: query IPluginManager.GetMethodSignature for plugin return types + } + } + + // ── Second pass: DEMANDED types from consumers ── + foreach (var block in formattedScript.Blocks) + { + foreach (var stmt in block.Statements) + { + // Branch/Loop condition demands bool + if ((stmt.Kind == FormattedStatementKind.Branch || stmt.Kind == FormattedStatementKind.Loop) + && !string.IsNullOrEmpty(stmt.ConditionPubVar) + && pubVarTypes.ContainsKey(stmt.ConditionPubVar)) + { + if (pubVarTypes[stmt.ConditionPubVar] == "object") + pubVarTypes[stmt.ConditionPubVar] = "bool"; + } + + // Helper function arguments demand specific types + if ((stmt.Kind == FormattedStatementKind.Assignment || stmt.Kind == FormattedStatementKind.Expression) + && stmt.FunctionName != null + && helperMap.TryGetValue(stmt.FunctionName, out var consumerHelper)) + { + for (int i = 0; i < stmt.Arguments.Count && i < consumerHelper.Parameters.Count; i++) + { + var arg = stmt.Arguments[i].Trim(); + if (pubVarTypes.ContainsKey(arg) && pubVarTypes[arg] == "object") + pubVarTypes[arg] = consumerHelper.Parameters[i].Type; + } + } + + // PluginCall arguments — TODO: query plugin method signature + // For now, PluginCall args that are PubVars remain "object" + } + } + + Log.Debug("[ScriptAssemblyCompiler] Type inference: {Count} PubVars typed: {Types}", + pubVarTypes.Count, + string.Join(", ", pubVarTypes.Select(kv => $"{kv.Key}={kv.Value}"))); + + return pubVarTypes; + } + + // ────────────────────────────────────────────── + // Phase 2: SyntaxFactory-based code generation + // ────────────────────────────────────────────── + + /// + /// Generates the complete for the compiled script. + /// Structure: + /// + /// namespace KitX.Core.Workflow.BlockScripting.Generated { + /// public class CompiledScript_<hash> : ICompiledBlockScript { + /// public static T ConvertTo<T>(object? value) { ... } + /// // Helper functions as static methods (typed, NO wrappers) + /// public void Run(BlockScriptExecutionGlobals G, CancellationToken ct) { + /// // Variable declarations + G.Set(...) sync + /// // while (true) { ct.ThrowIfCancellationRequested(); switch (G.NextBlock) { ... } } + /// } + /// } + /// } + /// + /// + private CompilationUnitSyntax GenerateCompilationUnit( + BlockScript script, + FormattedBlockScript formattedScript, + Dictionary pubVarTypes, + string hash) + { + var classDecl = ClassDeclaration($"CompiledScript_{hash}") + .AddModifiers(Token(SyntaxKind.PublicKeyword)) + .AddBaseListTypes( + SimpleBaseType(ParseTypeName(nameof(ICompiledBlockScript)))); + + // Add ConvertTo method + classDecl = classDecl.AddMembers(GenerateConvertToMethod()); + + // Add helper function methods (original typed versions ONLY — no __ wrappers) + var helperMembers = GenerateHelperFunctions(script.HelperFunctions); + if (helperMembers.Count > 0) + classDecl = classDecl.AddMembers(helperMembers.ToArray()); + + // Add Run method (from formatted script with typed PubVars) + var runMethod = GenerateRunMethod(script, formattedScript, pubVarTypes); + classDecl = classDecl.AddMembers(runMethod); + + var nsDecl = NamespaceDeclaration( + ParseName("KitX.Core.Workflow.BlockScripting.Generated")) + .AddMembers(classDecl); + + // Add usings + return CompilationUnit() + .AddUsings( + UsingDirective(ParseName("System")), + UsingDirective(ParseName("System.Threading")), + UsingDirective(ParseName("KitX.Core.Contract.Workflow")), + UsingDirective(ParseName("KitX.Core.Workflow.BlockScripting"))) + .AddMembers(nsDecl) + .NormalizeWhitespace(); + } + + /// + /// Generates the ConvertTo<T> static method — the universal type converter + /// that replaces all __HelperFuncXxx object-param wrappers. + /// + /// public static T ConvertTo<T>(object? value) + /// { + /// if (value is T t) return t; + /// if (value == null) return default!; + /// return (T)System.Convert.ChangeType(value, typeof(T)); + /// } + /// + /// + private MethodDeclarationSyntax GenerateConvertToMethod() + { + // Parameters: (object? value) + var valueParam = Parameter(Identifier("value")) + .WithType(NullableType(PredefinedType(Token(SyntaxKind.ObjectKeyword)))); + + var body = Block( + // if (value is T t) return t; + IfStatement( + IsPatternExpression( + IdentifierName("value"), + DeclarationPattern( + IdentifierName("T"), + SingleVariableDesignation(Identifier("t")))), + ReturnStatement(IdentifierName("t"))), + + // if (value == null) return default!; + IfStatement( + BinaryExpression(SyntaxKind.EqualsExpression, + IdentifierName("value"), + LiteralExpression(SyntaxKind.NullLiteralExpression)), + ReturnStatement( + PostfixUnaryExpression(SyntaxKind.SuppressNullableWarningExpression, + LiteralExpression(SyntaxKind.DefaultLiteralExpression)))), + + // return (T)System.Convert.ChangeType(value, typeof(T)); + ReturnStatement( + CastExpression(IdentifierName("T"), + InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("System"), IdentifierName("Convert")), + IdentifierName("ChangeType")), + ArgumentList(SeparatedList(new ArgumentSyntax[] + { + Argument(IdentifierName("value")), + Argument(TypeOfExpression(IdentifierName("T"))) + })))) + ) + ); + + return MethodDeclaration(IdentifierName("T"), Identifier("ConvertTo")) + .AddModifiers(Token(SyntaxKind.PublicKeyword), Token(SyntaxKind.StaticKeyword)) + .WithTypeParameterList(TypeParameterList(SeparatedList(new[] { TypeParameter("T") }))) + .WithParameterList(ParameterList(SeparatedList(new[] { valueParam }))) + .WithBody(body); + } + + /// + /// Generates static method declarations from definitions. + /// Only generates the original typed methods — NO __-prefixed wrappers. + /// The wrappers are replaced by ConvertTo<T> at PubVar assignment sites. + /// + private List GenerateHelperFunctions(List? helperFunctions) + { + var members = new List(); + if (helperFunctions == null || helperFunctions.Count == 0) + return members; + + foreach (var func in helperFunctions) + { + var paramList = ParameterList(SeparatedList( + func.Parameters.Select(p => + Parameter(Identifier(p.Name)) + .WithType(ParseTypeName(p.Type))))); + + var bodyStatements = ParseHelperFunctionBody(func.Code); + + var methodDecl = MethodDeclaration( + ParseTypeName(func.ReturnType), + Identifier(func.Name)) + .AddModifiers(Token(SyntaxKind.PublicKeyword), Token(SyntaxKind.StaticKeyword)) + .WithParameterList(paramList) + .WithBody(Block(bodyStatements)); + + members.Add(methodDecl); + } + + return members; + } + + /// + /// Parses helper function body code into a list of . + /// + private static List ParseHelperFunctionBody(string? code) + { + if (string.IsNullOrWhiteSpace(code)) + return new List { ReturnStatement(LiteralExpression(SyntaxKind.NullLiteralExpression)) }; + + // Wrap body in a method for valid C# parsing, then extract statements + var wrapper = $"void __wrapper() {{ {code} }}"; + var tree = CSharpSyntaxTree.ParseText(wrapper); + var root = tree.GetCompilationUnitRoot(); + + if (root.Members.FirstOrDefault() is GlobalStatementSyntax gs + && gs.Statement is LocalFunctionStatementSyntax localFunc) + { + return localFunc.Body!.Statements.ToList(); + } + + // Fallback: try parsing as a block + var blockTree = CSharpSyntaxTree.ParseText($"{{ {code} }}"); + var blockRoot = blockTree.GetCompilationUnitRoot(); + if (blockRoot.Members.FirstOrDefault() is GlobalStatementSyntax gs2 + && gs2.Statement is BlockSyntax block) + { + return block.Statements.ToList(); + } + + Log.Warning("[ScriptAssemblyCompiler] Failed to parse helper function body, using empty body"); + return new List { ReturnStatement(LiteralExpression(SyntaxKind.NullLiteralExpression)) }; + } + + // ────────────────────────────────────────────── + // Run method generation (from FormattedBlockScript) + // ────────────────────────────────────────────── + + /// + /// Generates the Run method containing the while-switch dispatcher. + /// Code is generated from the with typed PubVars + /// and ConvertTo<T> where needed. + /// + private MethodDeclarationSyntax GenerateRunMethod( + BlockScript script, + FormattedBlockScript formattedScript, + Dictionary pubVarTypes) + { + var statements = new List(); + + // G.ResetRunState(); + statements.Add(ExpressionStatement( + InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("G"), IdentifierName("ResetRunState"))))); + + // Variable declarations for ConstBlock/PubVarBlock + statements.AddRange(GenerateInitStatements(script)); + + // G.NextBlock = "MainBlock"; + statements.Add(ExpressionStatement( + AssignmentExpression(SyntaxKind.SimpleAssignmentExpression, + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("G"), IdentifierName("NextBlock")), + LiteralExpression(SyntaxKind.StringLiteralExpression, + Literal(formattedScript.MainBlockName))))); + + // while (true) { ... } + var whileBody = new List(); + + // ct.ThrowIfCancellationRequested(); + whileBody.Add(ExpressionStatement( + InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("ct"), IdentifierName("ThrowIfCancellationRequested"))))); + + // if (string.IsNullOrEmpty(G.NextBlock)) return; + whileBody.Add(IfStatement( + InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + ParseTypeName("string"), IdentifierName("IsNullOrEmpty")), + ArgumentList(SeparatedList(new[] + { + Argument(MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("G"), IdentifierName("NextBlock"))) + }))), + ReturnStatement())); + + // switch (G.NextBlock) { ... } + var switchSections = GenerateSwitchSections(formattedScript, pubVarTypes, script.HelperFunctions); + whileBody.Add(SwitchStatement( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("G"), IdentifierName("NextBlock")), + List(switchSections))); + + var whileStmt = WhileStatement( + LiteralExpression(SyntaxKind.TrueLiteralExpression), + Block(whileBody)); + + statements.Add(whileStmt); + + return MethodDeclaration( + PredefinedType(Token(SyntaxKind.VoidKeyword)), + Identifier("Run")) + .AddModifiers(Token(SyntaxKind.PublicKeyword)) + .WithParameterList(ParameterList(SeparatedList(new[] + { + Parameter(Identifier("G")) + .WithType(ParseTypeName(nameof(BlockScriptExecutionGlobals))), + Parameter(Identifier("ct")) + .WithType(ParseTypeName(nameof(CancellationToken))) + }))) + .WithBody(Block(statements)); + } + + /// + /// Generates variable initialization statements from ConstBlock/PubVarBlock. + /// + /// + /// Generates variable initialization statements from ConstBlock. + /// PubVarBlock variables are NOT declared in the outer scope — they get typed + /// declarations inside switch cases via type inference. Declaring them here would + /// cause CS0136 (variable already declared in enclosing scope). + /// Instead, we only emit G.Set("name", null) for PubVarBlock to initialize + /// the globals dictionary, without local variable declarations. + /// + private List GenerateInitStatements(BlockScript script) + { + var statements = new List(); + + // ConstBlock variables: full declaration + G.Set (they have known types and initial values) + if (script.ConstBlock != null) + { + foreach (var variable in script.ConstBlock.Variables) + statements.AddRange(GenerateVariableInit(variable)); + } + + // PubVarBlock variables: only G.Set("name", null) to initialize globals dictionary. + // No local variable declarations — these are declared with inferred types inside switch cases. + if (script.PubVarBlock != null) + { + foreach (var variable in script.PubVarBlock.Variables) + { + statements.Add(ExpressionStatement( + InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("G"), IdentifierName("Set")), + ArgumentList(SeparatedList(new[] + { + Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, + Literal(variable.Name))), + Argument(LiteralExpression(SyntaxKind.NullLiteralExpression)) + }))))); + } + } + + return statements; + } + + /// + /// Generates statements for a single variable declaration. + /// + private List GenerateVariableInit(VariableDeclaration decl) + { + var stmts = new List(); + + if (decl.DefaultValue != null) + { + var initExpr = !string.IsNullOrEmpty(decl.InitialValueExpression) + ? ParseExpression(decl.InitialValueExpression) + : FormatLiteralExpression(decl.Type, decl.DefaultValue); + + stmts.Add(LocalDeclarationStatement( + VariableDeclaration(IdentifierName("var")) + .AddVariables( + VariableDeclarator(Identifier(decl.Name)) + .WithInitializer(EqualsValueClause(initExpr))))); + + stmts.Add(ExpressionStatement( + InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("G"), IdentifierName("Set")), + ArgumentList(SeparatedList(new[] + { + Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(decl.Name))), + Argument(IdentifierName(decl.Name)) + }))))); + } + else if (!string.IsNullOrEmpty(decl.InitialValueExpression)) + { + stmts.Add(LocalDeclarationStatement( + VariableDeclaration(IdentifierName("var")) + .AddVariables( + VariableDeclarator(Identifier(decl.Name)) + .WithInitializer( + EqualsValueClause(ParseExpression(decl.InitialValueExpression)))))); + + stmts.Add(ExpressionStatement( + InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("G"), IdentifierName("Set")), + ArgumentList(SeparatedList(new[] + { + Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(decl.Name))), + Argument(IdentifierName(decl.Name)) + }))))); + } + else + { + stmts.Add(LocalDeclarationStatement( + VariableDeclaration(ParseTypeName(decl.Type)) + .AddVariables(VariableDeclarator(Identifier(decl.Name))))); + + stmts.Add(ExpressionStatement( + InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("G"), IdentifierName("Set")), + ArgumentList(SeparatedList(new[] + { + Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(decl.Name))), + Argument(LiteralExpression(SyntaxKind.NullLiteralExpression)) + }))))); + } + + return stmts; + } + + /// + /// Formats a literal value as a Roslyn . + /// + private static ExpressionSyntax FormatLiteralExpression(string type, object value) + { + return type switch + { + "string" => LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(value.ToString()!)), + "char" => LiteralExpression(SyntaxKind.CharacterLiteralExpression, Literal(char.Parse(value.ToString()!))), + "bool" => (bool)value + ? LiteralExpression(SyntaxKind.TrueLiteralExpression) + : LiteralExpression(SyntaxKind.FalseLiteralExpression), + "int" => LiteralExpression(SyntaxKind.NumericLiteralExpression, Literal((int)value)), + "long" => LiteralExpression(SyntaxKind.NumericLiteralExpression, Literal((long)value)), + "double" => LiteralExpression(SyntaxKind.NumericLiteralExpression, Literal((double)value)), + "float" => LiteralExpression(SyntaxKind.NumericLiteralExpression, Literal((float)value)), + _ => ParseExpression(value?.ToString() ?? "null") + }; + } + + // ────────────────────────────────────────────── + // Switch section generation (from FormattedBlock) + // ────────────────────────────────────────────── + + /// + /// Generates for each formatted block. + /// + private List GenerateSwitchSections( + FormattedBlockScript formattedScript, + Dictionary pubVarTypes, + List? helperFunctions) + { + var sections = new List(); + var helperReturnTypes = (helperFunctions ?? []) + .Where(h => h.Name != null) + .ToDictionary(h => h.Name!, h => h.ReturnType, StringComparer.Ordinal); + + foreach (var block in formattedScript.Blocks) + { + sections.Add(GenerateFormattedBlockCase(block, pubVarTypes, helperFunctions, helperReturnTypes)); + } + + // Default case: return (unknown block = end script) + sections.Add(SwitchSection() + .AddLabels(DefaultSwitchLabel()) + .AddStatements(ReturnStatement())); + + return sections; + } + + /// + /// Generates a single switch case from a . + /// Uses typed PubVars with ConvertTo<T> where the source is object + /// and the consumer needs a specific type. Helper functions are called directly + /// with typed arguments — no __-prefixed wrappers needed. + /// + private SwitchSectionSyntax GenerateFormattedBlockCase( + FormattedBlock block, + Dictionary pubVarTypes, + List? helperFunctions, + Dictionary helperReturnTypes) + { + var caseStatements = new List(); + + // G.ResetNextBlock(); + caseStatements.Add(ExpressionStatement( + InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("G"), IdentifierName("ResetNextBlock"))))); + + // G.ExecutedBlockCount++; + caseStatements.Add(ExpressionStatement( + PostfixUnaryExpression(SyntaxKind.PostIncrementExpression, + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("G"), IdentifierName("ExecutedBlockCount"))))); + + var hasNextBlockAssignment = false; + + foreach (var stmt in block.Statements) + { + switch (stmt.Kind) + { + case FormattedStatementKind.Assignment: + case FormattedStatementKind.Expression: + { + // ── Unified PubVar assignment: build raw expr, then wrap with ConvertTo if needed ── + // Both Assignment and Expression (promoted by ExpandCondition) produce PubVar declarations. + // The ConvertTo decision is always the same: if source returns object but target needs + // a specific type, wrap it. No per-source-type branching needed. + + // Step 1: Build raw call expression + ExpressionSyntax rawExpr; + string sourceType = "object"; // default: unknown source returns object + + if (stmt.FunctionName == "Get") + { + rawExpr = BuildGetInvocation(stmt.GetVarName ?? ""); + // Get always returns object + } + else if (IsHelperFunction(stmt.FunctionName, helperFunctions)) + { + var args = stmt.Arguments.Select(a => + Argument(ResolveArgumentExpression(a, pubVarTypes))).ToList(); + rawExpr = InvocationExpression(IdentifierName(stmt.FunctionName!), + ArgumentList(SeparatedList(args))); + // Helper source type = ReturnType + sourceType = helperReturnTypes.TryGetValue(stmt.FunctionName ?? "", out var rt) + ? rt : "object"; + } + else if (stmt.FullFunctionName != null && stmt.FullFunctionName.Contains('.')) + { + rawExpr = BuildPluginCallExpression(stmt, pubVarTypes); + // PluginCall returns object (until we query method signatures) + } + else + { + rawExpr = ParseExpression( + $"{stmt.FunctionName}({string.Join(", ", stmt.Arguments)})"); + // Unknown function, assume object + } + + // Step 2: If there's a PubVarTarget, emit typed declaration with ConvertTo if needed + if (stmt.PubVarTarget != null) + { + var typeName = pubVarTypes.GetValueOrDefault(stmt.PubVarTarget, "object"); + + // Unified ConvertTo rule: source is object but target is specific → wrap + ExpressionSyntax initExpr = (typeName != "object" && sourceType == "object") + ? BuildConvertToInvocation(typeName, rawExpr) + : rawExpr; + + caseStatements.Add(LocalDeclarationStatement( + VariableDeclaration(ParseTypeName(typeName)) + .AddVariables(VariableDeclarator(Identifier(stmt.PubVarTarget)) + .WithInitializer(EqualsValueClause(initExpr))))); + } + else + { + // No PubVar → standalone expression statement + caseStatements.Add(ExpressionStatement(rawExpr)); + } + + break; + } + + case FormattedStatementKind.Branch: + { + // G.NextBlock = G.Branch(cond, "TrueBlock", "FalseBlock"); + var condExpr = !string.IsNullOrEmpty(stmt.ConditionPubVar) + ? ResolveArgumentExpression(stmt.ConditionPubVar, pubVarTypes) + : ParseExpression(stmt.ConditionExpression ?? "false"); + + caseStatements.Add(ExpressionStatement( + AssignmentExpression(SyntaxKind.SimpleAssignmentExpression, + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("G"), IdentifierName("NextBlock")), + InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("G"), IdentifierName("Branch")), + ArgumentList(SeparatedList(new[] + { + Argument(condExpr), + Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, + Literal(stmt.TrueBlockName ?? ""))), + Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, + Literal(stmt.FalseBlockName ?? ""))) + })))))); + + hasNextBlockAssignment = true; + caseStatements.Add(BreakStatement()); // break exits switch → loops back to while + break; + } + + case FormattedStatementKind.Loop: + { + // G.NextBlock = G.Loop(cond, "LoopBody", "LoopEnd"); + var condExpr = !string.IsNullOrEmpty(stmt.ConditionPubVar) + ? ResolveArgumentExpression(stmt.ConditionPubVar, pubVarTypes) + : ParseExpression(stmt.ConditionExpression ?? "false"); + + caseStatements.Add(ExpressionStatement( + AssignmentExpression(SyntaxKind.SimpleAssignmentExpression, + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("G"), IdentifierName("NextBlock")), + InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("G"), IdentifierName("Loop")), + ArgumentList(SeparatedList(new[] + { + Argument(condExpr), + Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, + Literal(stmt.TrueBlockName ?? ""))), + Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, + Literal(stmt.FalseBlockName ?? ""))) + })))))); + + hasNextBlockAssignment = true; + caseStatements.Add(BreakStatement()); + break; + } + + case FormattedStatementKind.Print: + { + // G.Print(arg); + if (stmt.Arguments.Count > 0) + { + var argExpr = ResolveArgumentExpression(stmt.Arguments[0], pubVarTypes); + caseStatements.Add( + ExpressionStatement( + InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("G"), IdentifierName("Print")), + ArgumentList(SeparatedList(new[] { Argument(argExpr) }))))); + } + + break; + } + + case FormattedStatementKind.Set: + { + // G.Set("varName", value); + var varName = stmt.SetVarName ?? ""; + if (stmt.Arguments.Count > 0) + { + var valueExpr = ResolveArgumentExpression(stmt.Arguments[0], pubVarTypes); + caseStatements.Add( + ExpressionStatement( + InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("G"), IdentifierName("Set")), + ArgumentList(SeparatedList(new[] + { + Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(varName))), + Argument(valueExpr) + }))))); + } + + break; + } + + case FormattedStatementKind.ToLoopCond: + { + // G.NextBlock = G.ToLoopCond("parentBlock"); + caseStatements.Add( + ExpressionStatement( + AssignmentExpression(SyntaxKind.SimpleAssignmentExpression, + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("G"), IdentifierName("NextBlock")), + InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("G"), IdentifierName("ToLoopCond")), + ArgumentList(SeparatedList(new[] + { + Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, + Literal(stmt.ToLoopCondReturnTo ?? ""))) + })))))); + + hasNextBlockAssignment = true; + caseStatements.Add(BreakStatement()); + break; + } + + case FormattedStatementKind.Break: + { + // Exit the Run method entirely + caseStatements.Add(ReturnStatement()); + break; + } + + case FormattedStatementKind.Pause: + { + // G.Pause(ms); + if (stmt.Arguments.Count > 0) + { + var msExpr = ResolveArgumentExpression(stmt.Arguments[0], pubVarTypes); + caseStatements.Add( + ExpressionStatement( + InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("G"), IdentifierName("Pause")), + ArgumentList(SeparatedList(new[] { Argument(msExpr) }))))); + } + + break; + } + + default: + // Unknown FormattedStatementKind — skip + break; + } + } + + // Auto-complete NextBlock if block has NextBlockName and no explicit assignment + if (!hasNextBlockAssignment && !string.IsNullOrEmpty(block.NextBlockName)) + { + caseStatements.Add(ExpressionStatement( + AssignmentExpression(SyntaxKind.SimpleAssignmentExpression, + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("G"), IdentifierName("NextBlock")), + LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(block.NextBlockName))))); + } + + // Trailing break: exits switch → continues to while(true) top. + // Skip if the case already ends with break (flow control) or return. + if (!caseStatements.Any(s => s is BreakStatementSyntax or ReturnStatementSyntax)) + caseStatements.Add(BreakStatement()); + + return SwitchSection() + .AddLabels(CaseSwitchLabel( + LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(block.Name)))) + .AddStatements(caseStatements.ToArray()); + } + + // ────────────────────────────────────────────── + // Expression builders + // ────────────────────────────────────────────── + + /// + /// Builds ConvertTo<T>(arg) expression. + /// Uses for the method name so the type argument + /// is part of the expression, not a separate parameter to . + /// + private static InvocationExpressionSyntax BuildConvertToInvocation(string typeName, ExpressionSyntax argExpr) + { + return InvocationExpression( + GenericName(Identifier("ConvertTo"), + TypeArgumentList(SeparatedList(new TypeSyntax[] { ParseTypeName(typeName) }))), + ArgumentList(SeparatedList(new[] { Argument(argExpr) }))); + } + + /// + /// Builds G.Get<object>("varName") expression. + /// + private static InvocationExpressionSyntax BuildGetInvocation(string varName) + { + return InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("G"), + GenericName(Identifier("Get"), + TypeArgumentList(SeparatedList(new TypeSyntax[] + { PredefinedType(Token(SyntaxKind.ObjectKeyword)) })))), + ArgumentList(SeparatedList(new[] + { + Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(varName))) + }))); + } + + /// + /// Builds G.PluginCall("pluginName", "methodName", args...) expression + /// from a with a dotted . + /// + private static InvocationExpressionSyntax BuildPluginCallExpression( + FormattedStatement stmt, Dictionary pubVarTypes) + { + var lastDot = (stmt.FullFunctionName ?? "").LastIndexOf('.'); + var pluginName = lastDot >= 0 ? stmt.FullFunctionName![..lastDot] : stmt.FullFunctionName ?? ""; + var methodName = lastDot >= 0 ? stmt.FullFunctionName![(lastDot + 1)..] : ""; + + var pluginCallArgs = new List + { + Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(pluginName))), + Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(methodName))) + }; + pluginCallArgs.AddRange(stmt.Arguments.Select(a => + Argument(ResolveArgumentExpression(a, pubVarTypes)))); + + return InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("G"), IdentifierName("PluginCall")), + ArgumentList(SeparatedList(pluginCallArgs))); + } + + /// + /// Resolves a formatted argument string into a Roslyn . + /// PubVar references are returned as . + /// All other values are parsed via . + /// + private static ExpressionSyntax ResolveArgumentExpression( + string arg, Dictionary pubVarTypes) + { + arg = arg.Trim(); + + // PubVar reference — always use as identifier + if (pubVarTypes.ContainsKey(arg)) + return IdentifierName(arg); + + // Fallback: parse as C# expression (handles literals, variable names, etc.) + var parsed = ParseExpression(arg); + return parsed ?? IdentifierName(arg); + } + + /// + /// Checks whether a function name corresponds to a registered HelperFunction. + /// + private static bool IsHelperFunction(string? name, List? helperFunctions) + { + if (name == null || helperFunctions == null) return false; + return helperFunctions.Any(h => h.Name == name); + } + + // ────────────────────────────────────────────── + // Phase 3: Roslyn compilation + // ────────────────────────────────────────────── + + /// + /// Compiles the generated into a .NET assembly. + /// + private MemoryStream? CompileToAssembly(CompilationUnitSyntax compilationUnit, string hash) + { + var normalized = compilationUnit.NormalizeWhitespace(); + var sourceText = normalized.ToFullString(); + + Log.Debug("[ScriptAssemblyCompiler] Generated source code for hash '{Hash}':\n{Source}", hash, sourceText); + + var syntaxTree = CSharpSyntaxTree.Create(normalized, + path: $"CompiledScript_{hash}.cs"); + + var compilation = CSharpCompilation.Create( + $"CompiledScript_{hash}", + syntaxTrees: new[] { syntaxTree }, + references: GetCompilationReferences(), + options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + .WithOptimizationLevel(OptimizationLevel.Release) + .WithAssemblyIdentityComparer(DesktopAssemblyIdentityComparer.Default)); + + var assemblyStream = new MemoryStream(); + var emitResult = compilation.Emit(assemblyStream); + + if (!emitResult.Success) + { + var diagnostics = emitResult.Diagnostics + .Where(d => d.Severity >= DiagnosticSeverity.Error) + .Select(d => d.ToString()) + .ToList(); + + Log.Warning("[ScriptAssemblyCompiler] Compilation failed with {ErrorCount} errors:", + diagnostics.Count); + foreach (var diag in diagnostics.Take(10)) + { + Log.Warning("[ScriptAssemblyCompiler] {Diagnostic}", diag); + } + + return null; + } + + assemblyStream.Position = 0; + Log.Debug("[ScriptAssemblyCompiler] Assembly compiled successfully: {Size} bytes", + assemblyStream.Length); + return assemblyStream; + } + + /// + /// Gets the set of s needed for compilation. + /// + private static List GetCompilationReferences() + { + var references = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + var seedAssemblies = new Assembly[] + { + typeof(BlockScriptExecutionGlobals).Assembly, + typeof(KitX.Core.Contract.Workflow.BlockScript).Assembly, + typeof(ICompiledBlockScript).Assembly, + typeof(Kscript.CSharp.Parser.Models.PluginCallInfo).Assembly, + typeof(Microsoft.CSharp.RuntimeBinder.Binder).Assembly, + typeof(object).Assembly, + typeof(System.Collections.Generic.List<>).Assembly, + typeof(System.Linq.Enumerable).Assembly, + }; + + foreach (var assembly in seedAssemblies) + { + if (string.IsNullOrEmpty(assembly.Location)) continue; + if (!seen.Add(assembly.Location)) continue; + references.Add(MetadataReference.CreateFromFile(assembly.Location)); + } + + foreach (var assembly in AssemblyLoadContext.Default.Assemblies) + { + if (assembly.IsDynamic || string.IsNullOrEmpty(assembly.Location)) continue; + if (!seen.Add(assembly.Location)) continue; + + references.Add(MetadataReference.CreateFromFile(assembly.Location)); + } + + Log.Debug("[ScriptAssemblyCompiler] Compilation references: {Count} assemblies", references.Count); + return references; + } + + // ────────────────────────────────────────────── + // Utilities + // ────────────────────────────────────────────── + + /// + /// Computes a deterministic hash for a BlockScript based on its structural content. + /// + private static string ComputeScriptHash(BlockScript script) + { + var hashInput = new System.Text.StringBuilder(); + + foreach (var block in script.AllBlocks) + { + hashInput.Append($"[{block.Name}:{block.Type}:{block.NextBlockName}]"); + foreach (var stmt in block.Statements) + { + hashInput.Append($"<{stmt.SourceCode}>"); + } + } + + foreach (var helper in script.HelperFunctions ?? []) + { + hashInput.Append($"{{H:{helper.Name}:{helper.Code}}}"); + } + + var hash = 0; + foreach (var c in hashInput.ToString()) + hash = (hash * 31 + c) & 0x7FFFFFFF; + + return hash.ToString("x8"); + } + + // ────────────────────────────────────────────── + // CollectibleAssemblyLoadContext + // ────────────────────────────────────────────── + + private class CollectibleAssemblyLoadContext : AssemblyLoadContext + { + public CollectibleAssemblyLoadContext(string name) : base(name, isCollectible: true) { } + } + + private class CompiledScriptEntry + { + public ICompiledBlockScript Instance { get; } + private readonly CollectibleAssemblyLoadContext _alc; + + public CompiledScriptEntry(ICompiledBlockScript instance, CollectibleAssemblyLoadContext alc) + { + Instance = instance; + _alc = alc; + } + + public bool IsAlive => true; + + public void Unload() + { + try + { + _alc.Unload(); + } + catch (Exception ex) + { + Log.Debug(ex, "[ScriptAssemblyCompiler] Error unloading assembly context"); + } + } + } +} \ No newline at end of file diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ScriptFormatter.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ScriptFormatter.cs index b14205e4..5bcbf93d 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ScriptFormatter.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ScriptFormatter.cs @@ -256,7 +256,10 @@ private List FormatInvocation( if (!string.IsNullOrEmpty(assignedVar) && assignedVar != "_") { kind = FormattedStatementKind.Assignment; - pubVarTarget = context.PubVarNames.Contains(assignedVar) ? assignedVar : null; + pubVarTarget = assignedVar; + // Ensure the assigned variable is tracked as a PubVar + if (!context.PubVarNames.Contains(assignedVar)) + context.PubVarNames.Add(assignedVar); } else { From 5a0904d217a2a0ef8cbd69cff1303d192f9eeade Mon Sep 17 00:00:00 2001 From: StarInk Date: Tue, 14 Apr 2026 17:56:13 +0200 Subject: [PATCH 056/110] =?UTF-8?q?=F0=9F=92=BE=20Feat(Workflow):=20?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=B7=A5=E4=BD=9C=E6=B5=81=E7=BC=96=E8=AF=91?= =?UTF-8?q?=E4=B8=8E=E6=8C=81=E4=B9=85=E5=8C=96=E5=8A=9F=E8=83=BD=EF=BC=8C?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E8=B7=A8=E4=BC=9A=E8=AF=9D=E9=87=8D=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BlockScriptExecutionGlobals.cs | 3 +- .../BlockScripting/BlockScriptExecutor.cs | 50 +++- .../BlockScripting/CompiledScriptMeta.cs | 32 +++ .../BlockScripting/ScriptAssemblyCompiler.cs | 245 +++++++++++++++++- .../KitX.Core/Workflow/RealPluginManager.cs | 30 ++- .../Workflow/WorkflowScriptService.cs | 74 ++++++ .../Workflow/WorkflowStorageService.cs | 30 +++ KitX Standard | 2 +- 8 files changed, 447 insertions(+), 19 deletions(-) create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/CompiledScriptMeta.cs diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutionGlobals.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutionGlobals.cs index 42e6cf60..44e54e54 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutionGlobals.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutionGlobals.cs @@ -1,8 +1,7 @@ using System; using System.Collections.Generic; using System.Threading; -using Kscript.CSharp.Parser.Core; -using Kscript.CSharp.Parser.Models; +using KitX.Core.Contract.Workflow; using KitX.Core.Workflow; using Serilog; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutor.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutor.cs index d8c5a792..84b804e9 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutor.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutor.cs @@ -5,13 +5,12 @@ using System.Reflection; using System.Threading; using System.Threading.Tasks; -using Kscript.CSharp.Parser.Core; +using KitX.Core.Contract.Workflow; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.CSharp.Scripting; using Microsoft.CodeAnalysis.Scripting; -using KitX.Core.Contract.Workflow; using KitX.Core.Workflow; using Serilog; @@ -40,6 +39,9 @@ public class BlockScriptExecutor : IBlockScriptExecutor private BlockScriptExecutionGlobals? _globals; private IPluginManager? _pluginManager; + // Workflow ID for disk persistence of compiled assemblies + private string? _workflowId; + // Block-level precompilation private readonly BlockCompiler _blockCompiler = new(); private Dictionary? _blockCodeCache; @@ -80,6 +82,48 @@ public void SetPluginManager(IPluginManager? pluginManager) _pluginManager = pluginManager; } + /// + /// Sets the workflow ID for disk persistence of compiled assemblies. + /// When set, compiled assemblies are saved to and loaded from disk + /// to enable cross-session reuse. + /// + public void SetWorkflowId(string? workflowId) + { + _workflowId = workflowId; + } + + /// + /// Compiles a BlockScript and persists it to disk (without executing). + /// Used for pre-compilation at workflow save time. + /// + /// The block script to compile. + /// Workflow ID for disk persistence. + /// True if compilation and persistence succeeded. + public bool CompileForPersistence(BlockScript script, string workflowId) + { + try + { + var compiled = _assemblyCompiler.CompileScript(script, workflowId); + return compiled != null; + } + catch (Exception ex) + { + Log.Warning(ex, "[BlockScriptExecutor] CompileForPersistence failed for workflow {WfId}", workflowId); + return false; + } + } + + /// + /// Preloads all persisted compiled scripts for a workflow from disk + /// into the in-memory cache. + /// + /// Workflow ID to preload scripts for. + /// Number of scripts successfully loaded. + public int PreloadFromDisk(string workflowId) + { + return _assemblyCompiler.PreloadFromDisk(workflowId); + } + /// /// Executes a block script /// @@ -100,7 +144,7 @@ public async Task ExecuteAsync( // On failure, falls back to the existing CSharpScript execution path. try { - var compiled = _assemblyCompiler.CompileScript(script); + var compiled = _assemblyCompiler.CompileScript(script, _workflowId); if (compiled != null) { Log.Debug("[BlockScriptExecutor] Using assembly-compiled execution path"); diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/CompiledScriptMeta.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/CompiledScriptMeta.cs new file mode 100644 index 00000000..32c2efaa --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/CompiledScriptMeta.cs @@ -0,0 +1,32 @@ +using System; + +namespace KitX.Core.Workflow.BlockScripting; + +/// +/// Metadata for a persisted compiled script assembly on disk. +/// Stored alongside the .dll as {hash}.meta.json. +/// +internal class CompiledScriptMeta +{ + /// + /// Script hash — must match the .dll filename and the in-memory cache key. + /// + public string ScriptHash { get; set; } = string.Empty; + + /// + /// UTC timestamp when the assembly was compiled. + /// + public DateTime CompileTimeUtc { get; set; } = DateTime.UtcNow; + + /// + /// KitX assembly version at compile time. + /// Mismatch triggers re-compilation (API surface may have changed). + /// + public string KitXVersion { get; set; } = string.Empty; + + /// + /// Full type name of the compiled script class in the assembly, + /// e.g. "KitX.Core.Workflow.BlockScripting.Generated.CompiledScript_1a2b3c4d". + /// + public string TypeName { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptAssemblyCompiler.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptAssemblyCompiler.cs index 266e20b9..a32fa5c1 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptAssemblyCompiler.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptAssemblyCompiler.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Reflection; using System.Runtime.Loader; +using System.Text.Json; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; @@ -55,6 +56,21 @@ internal class ScriptAssemblyCompiler /// private readonly Dictionary _cache = new(); + /// + /// JSON serializer options for meta.json persistence. + /// + private static readonly JsonSerializerOptions _metaJsonOptions = new() + { + WriteIndented = true, + PropertyNameCaseInsensitive = true, + }; + + /// + /// Root directory for persisted compiled script assemblies. + /// Each workflow gets a subdirectory: Data/CompiledScripts/{workflow-id}/ + /// + private static readonly string CompiledScriptsRoot = Path.Combine("./Data/", "CompiledScripts"); + // ────────────────────────────────────────────── // Public API // ────────────────────────────────────────────── @@ -63,17 +79,44 @@ internal class ScriptAssemblyCompiler /// Compiles a into an . /// Returns null if compilation fails (caller should fall back to CSharpScript). /// - public ICompiledBlockScript? CompileScript(BlockScript script) + public ICompiledBlockScript? CompileScript(BlockScript script) => + CompileScript(script, workflowId: null); + + /// + /// Compiles a into an , + /// with optional disk persistence for cross-session reuse. + /// + /// The block script to compile. + /// + /// Optional workflow ID for disk persistence. When provided, the compiled assembly + /// is saved to Data/CompiledScripts/{workflowId}/{hash}.dll and reused on + /// subsequent calls instead of recompiling. + /// + /// Compiled script instance, or null on failure. + public ICompiledBlockScript? CompileScript(BlockScript script, string? workflowId) { var hash = ComputeScriptHash(script); - // Check cache + // Step 1: Check in-memory cache if (_cache.TryGetValue(hash, out var entry) && entry.IsAlive) { - Log.Debug("[ScriptAssemblyCompiler] Cache hit for script hash '{Hash}'", hash); + Log.Debug("[ScriptAssemblyCompiler] Memory cache hit for hash '{Hash}'", hash); return entry.Instance; } + // Step 2: Try loading from disk (if workflowId provided) + if (workflowId != null) + { + var diskInstance = TryLoadFromDisk(workflowId, hash); + if (diskInstance != null) + { + Log.Debug("[ScriptAssemblyCompiler] Disk cache hit for hash '{Hash}' (workflow: {WfId})", + hash, workflowId); + return diskInstance; + } + } + + // Step 3: Roslyn compilation (existing flow) try { // Phase 1: Format script + infer PubVar types @@ -90,8 +133,8 @@ internal class ScriptAssemblyCompiler // Phase 4: Load into collectible ALContext and instantiate var alc = new CollectibleAssemblyLoadContext(hash); var loadedAssembly = alc.LoadFromStream(assembly); - var scriptType = loadedAssembly.GetType( - $"KitX.Core.Workflow.BlockScripting.Generated.CompiledScript_{hash}"); + var typeName = $"KitX.Core.Workflow.BlockScripting.Generated.CompiledScript_{hash}"; + var scriptType = loadedAssembly.GetType(typeName); if (scriptType == null) { Log.Warning("[ScriptAssemblyCompiler] Compiled type not found in assembly"); @@ -101,9 +144,15 @@ internal class ScriptAssemblyCompiler var instance = (ICompiledBlockScript)Activator.CreateInstance(scriptType)!; - // Cache + // Cache in memory _cache[hash] = new CompiledScriptEntry(instance, alc); + // Step 5: Persist to disk (if workflowId provided) + if (workflowId != null) + { + SaveToDisk(workflowId, hash, assembly, typeName); + } + Log.Debug("[ScriptAssemblyCompiler] Successfully compiled and cached script hash '{Hash}'", hash); return instance; } @@ -124,6 +173,188 @@ public void ClearCache() _cache.Clear(); } + // ────────────────────────────────────────────── + // Disk persistence + // ────────────────────────────────────────────── + + /// + /// Attempts to load a compiled script assembly from disk. + /// Validates KitX version match before loading; stale assemblies are deleted. + /// + /// Loaded instance, or null if not found / stale / corrupt. + private ICompiledBlockScript? TryLoadFromDisk(string workflowId, string hash) + { + var dir = Path.Combine(CompiledScriptsRoot, workflowId); + var dllPath = Path.Combine(dir, $"{hash}.dll"); + var metaPath = Path.Combine(dir, $"{hash}.meta.json"); + + if (!File.Exists(dllPath) || !File.Exists(metaPath)) + return null; + + try + { + var metaJson = File.ReadAllText(metaPath); + var meta = JsonSerializer.Deserialize(metaJson, _metaJsonOptions); + if (meta == null) + { + Log.Debug("[ScriptAssemblyCompiler] Corrupt meta.json for hash '{Hash}', deleting", hash); + DeleteFromDisk(workflowId, hash); + return null; + } + + // Version validation: mismatch means API surface may have changed + var currentVersion = GetCurrentKitXVersion(); + if (meta.KitXVersion != currentVersion) + { + Log.Debug("[ScriptAssemblyCompiler] KitX version mismatch for hash '{Hash}': " + + "disk={DiskVer}, current={CurrentVer}. Deleting and recompiling.", + hash, meta.KitXVersion, currentVersion); + DeleteFromDisk(workflowId, hash); + return null; + } + + // Type name validation + if (string.IsNullOrEmpty(meta.TypeName)) + { + Log.Debug("[ScriptAssemblyCompiler] Missing TypeName in meta for hash '{Hash}', deleting", hash); + DeleteFromDisk(workflowId, hash); + return null; + } + + // Load assembly from disk + var dllBytes = File.ReadAllBytes(dllPath); + var alc = new CollectibleAssemblyLoadContext(hash); + var loadedAssembly = alc.LoadFromStream(new MemoryStream(dllBytes)); + + var scriptType = loadedAssembly.GetType(meta.TypeName); + if (scriptType == null) + { + Log.Debug("[ScriptAssemblyCompiler] Type '{TypeName}' not found in disk assembly for hash '{Hash}', deleting", + meta.TypeName, hash); + alc.Unload(); + DeleteFromDisk(workflowId, hash); + return null; + } + + var instance = (ICompiledBlockScript)Activator.CreateInstance(scriptType)!; + _cache[hash] = new CompiledScriptEntry(instance, alc); + + return instance; + } + catch (Exception ex) + { + Log.Debug(ex, "[ScriptAssemblyCompiler] Error loading from disk for hash '{Hash}', deleting", hash); + DeleteFromDisk(workflowId, hash); + return null; + } + } + + /// + /// Persists a compiled assembly and its metadata to disk. + /// + private void SaveToDisk(string workflowId, string hash, MemoryStream assemblyBytes, string typeName) + { + try + { + var dir = Path.Combine(CompiledScriptsRoot, workflowId); + Directory.CreateDirectory(dir); + + var dllPath = Path.Combine(dir, $"{hash}.dll"); + var metaPath = Path.Combine(dir, $"{hash}.meta.json"); + + // Write assembly bytes + File.WriteAllBytes(dllPath, assemblyBytes.ToArray()); + + // Write metadata + var meta = new CompiledScriptMeta + { + ScriptHash = hash, + CompileTimeUtc = DateTime.UtcNow, + KitXVersion = GetCurrentKitXVersion(), + TypeName = typeName + }; + var metaJson = JsonSerializer.Serialize(meta, _metaJsonOptions); + File.WriteAllText(metaPath, metaJson); + + Log.Debug("[ScriptAssemblyCompiler] Persisted compiled script hash '{Hash}' to disk (workflow: {WfId})", + hash, workflowId); + } + catch (Exception ex) + { + Log.Debug(ex, "[ScriptAssemblyCompiler] Failed to persist compiled script to disk for hash '{Hash}'", hash); + } + } + + /// + /// Deletes persisted assembly files from disk. + /// + private static void DeleteFromDisk(string workflowId, string hash) + { + try + { + var dir = Path.Combine(CompiledScriptsRoot, workflowId); + var dllPath = Path.Combine(dir, $"{hash}.dll"); + var metaPath = Path.Combine(dir, $"{hash}.meta.json"); + + if (File.Exists(dllPath)) File.Delete(dllPath); + if (File.Exists(metaPath)) File.Delete(metaPath); + } + catch (Exception ex) + { + Log.Debug(ex, "[ScriptAssemblyCompiler] Error deleting disk cache for hash '{Hash}'", hash); + } + } + + /// + /// Preloads all persisted compiled scripts for a given workflow from disk + /// into the in-memory cache. Called at startup or when a workflow is first accessed. + /// + /// Workflow ID to preload scripts for. + /// Number of scripts successfully loaded into cache. + public int PreloadFromDisk(string workflowId) + { + var dir = Path.Combine(CompiledScriptsRoot, workflowId); + if (!Directory.Exists(dir)) + return 0; + + var count = 0; + foreach (var metaPath in Directory.GetFiles(dir, "*.meta.json")) + { + try + { + var metaJson = File.ReadAllText(metaPath); + var meta = JsonSerializer.Deserialize(metaJson, _metaJsonOptions); + if (meta == null || string.IsNullOrEmpty(meta.ScriptHash)) + continue; + + // Skip if already in memory cache + if (_cache.ContainsKey(meta.ScriptHash)) + continue; + + if (TryLoadFromDisk(workflowId, meta.ScriptHash) != null) + count++; + } + catch (Exception ex) + { + Log.Debug(ex, "[ScriptAssemblyCompiler] Error preloading from {Path}", metaPath); + } + } + + if (count > 0) + Log.Debug("[ScriptAssemblyCompiler] Preloaded {Count} compiled scripts for workflow {WfId}", + count, workflowId); + + return count; + } + + /// + /// Gets the current KitX assembly version string for disk cache invalidation. + /// + private static string GetCurrentKitXVersion() + { + return typeof(ScriptAssemblyCompiler).Assembly.GetName().Version?.ToString() ?? "0.0.0.0"; + } + // ────────────────────────────────────────────── // Phase 1: Format + Type Inference // ────────────────────────────────────────────── @@ -1091,7 +1322,7 @@ private static List GetCompilationReferences() typeof(BlockScriptExecutionGlobals).Assembly, typeof(KitX.Core.Contract.Workflow.BlockScript).Assembly, typeof(ICompiledBlockScript).Assembly, - typeof(Kscript.CSharp.Parser.Models.PluginCallInfo).Assembly, + typeof(KitX.Core.Contract.Workflow.PluginCallInfo).Assembly, typeof(Microsoft.CSharp.RuntimeBinder.Binder).Assembly, typeof(object).Assembly, typeof(System.Collections.Generic.List<>).Assembly, diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/RealPluginManager.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/RealPluginManager.cs index 7051ab4b..c2dfa486 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/RealPluginManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/RealPluginManager.cs @@ -4,8 +4,7 @@ using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using Kscript.CSharp.Parser.Core; -using Kscript.CSharp.Parser.Models; +using KitX.Core.Contract.Workflow; using KitX.Core.Contract.Plugin; using KitX.Core.Device; using KitX.Shared.CSharp.Plugin; @@ -13,12 +12,17 @@ using KitX.Shared.CSharp.WebCommand.Infos; using Serilog; +using KcsPluginCallInfo = Kscript.CSharp.Parser.Models.PluginCallInfo; +using KcsIPluginManager = Kscript.CSharp.Parser.Core.IPluginManager; + namespace KitX.Core.Workflow; /// -/// 真实的插件管理器实现,通过 WebSocket 与插件通信 +/// 真实的插件管理器实现,通过 WebSocket 与插件通信。 +/// Implements both Contract.IPluginManager (primary, for BlockScripting) and +/// KCS IPluginManager (legacy, for KCS pipeline compatibility). /// -public class RealPluginManager : IPluginManager +public class RealPluginManager : IPluginManager, KcsIPluginManager { private readonly PluginsServer _pluginsServer; private readonly JsonSerializerOptions _serializerOptions = new() @@ -132,7 +136,7 @@ private void OnPluginMessageReceived(object? sender, PluginMessageReceivedEventA } /// - /// 调用插件方法(无返回值) + /// 调用插件方法(无返回值) — Contract.IPluginManager /// /// /// 使用 fire-and-forget 模式,不等待插件响应。 @@ -145,7 +149,7 @@ public void Call(PluginCallInfo callInfo) } /// - /// 调用插件方法(有返回值) + /// 调用插件方法(有返回值) — Contract.IPluginManager /// public T Call(PluginCallInfo callInfo) { @@ -154,6 +158,20 @@ public T Call(PluginCallInfo callInfo) return ParseResult(result); } + // ── KCS IPluginManager explicit implementation (legacy compatibility) ── + + void KcsIPluginManager.Call(KcsPluginCallInfo callInfo) + => Call(ConvertFromKcs(callInfo)); + + T KcsIPluginManager.Call(KcsPluginCallInfo callInfo) + => Call(ConvertFromKcs(callInfo)); + + bool KcsIPluginManager.IsPluginExists(string pluginName) => IsPluginExists(pluginName); + bool KcsIPluginManager.IsMethodExists(string pluginName, string methodName) => IsMethodExists(pluginName, methodName); + + private static PluginCallInfo ConvertFromKcs(KcsPluginCallInfo kcs) + => new(kcs.PluginName, kcs.MethodName, kcs.Parameters, kcs.ParameterTypes, kcs.ParameterNames); + /// /// 自动调用插件方法:根据函数声明的返回类型自动选择调用策略。 /// - void 返回类型 → fire-and-forget (Call),不等待响应 diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs index a9fb1cfa..74bfcafd 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs @@ -140,6 +140,9 @@ public async Task RunWorkflowAsync(string workflowId) Log.Information("[{Location}] Executing workflow '{Name}' ({SourceLength} chars)...", location, data.Name, sourceCode.Length); + // Set workflow ID for disk persistence of compiled assemblies + BlockScriptExecutor.SetWorkflowId(workflowId); + var result = await ExecuteBlockScriptAsync(sourceCode, helpers ?? [], CancellationToken.None); if (result.IsSuccess) @@ -909,6 +912,77 @@ private async Task ExecuteBlockScriptCoreAsync( return await BlockScriptExecutor.ExecuteAsync(parseResult.Script, constantOverrides, cancellationToken); } + /// + public async Task CompileAndPersistWorkflowAsync(string workflowId) + { + const string location = $"{nameof(WorkflowScriptService)}.{nameof(CompileAndPersistWorkflowAsync)}"; + + try + { + var storageService = WorkflowStorageService.Instance; + var data = await storageService.LoadWorkflowDataAsync(workflowId); + + if (data == null) + { + Log.Warning("[{Location}] Workflow data not found for ID: {WorkflowId}", location, workflowId); + return false; + } + + if (!data.UseBlockMode || string.IsNullOrWhiteSpace(data.BlockScriptSource)) + { + Log.Warning("[{Location}] Workflow '{Name}' has no BlockScript source to compile", + location, data.Name); + return false; + } + + // Parse the BlockScript + var parseResult = BlockScriptParser.Parse(data.BlockScriptSource); + if (!parseResult.IsSuccess || parseResult.Script == null) + { + Log.Warning("[{Location}] Failed to parse BlockScript for workflow '{Name}': {Error}", + location, data.Name, parseResult.ErrorMessage); + return false; + } + + // Attach helper functions + if (data.HelperFunctions != null) + parseResult.Script.HelperFunctions = data.HelperFunctions; + + // Compile with workflowId for disk persistence + BlockScriptExecutor.SetWorkflowId(workflowId); + var compiled = BlockScriptExecutor.CompileForPersistence(parseResult.Script, workflowId); + + if (compiled) + { + Log.Information("[{Location}] Compiled and persisted workflow '{Name}' (ID: {WorkflowId})", + location, data.Name, workflowId); + } + else + { + Log.Warning("[{Location}] Compilation failed for workflow '{Name}' (ID: {WorkflowId})", + location, data.Name, workflowId); + } + + return compiled; + } + catch (Exception ex) + { + Log.Error(ex, "[{Location}] Error compiling workflow {WorkflowId}", location, workflowId); + return false; + } + } + + /// + /// Preloads all persisted compiled scripts for a workflow from disk. + /// Called at startup or when a workflow is first accessed. + /// + /// Workflow ID to preload scripts for. + /// Number of scripts loaded from disk. + public int PreloadCompiledScripts(string workflowId) + { + return BlockScriptExecutor.PreloadFromDisk(workflowId); + } + #endregion #endregion diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowStorageService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowStorageService.cs index 9ec68060..6033817a 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowStorageService.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowStorageService.cs @@ -197,6 +197,36 @@ public string GetWorkflowFilePath(string workflowId) return Path.Combine(_storageDirectory, $"{workflowId}.kcs"); } + /// + public async Task PreloadCompiledScriptsAsync() + { + EnsureDirectoryExists(); + + var workflows = await DiscoverWorkflowsAsync(); + var totalLoaded = 0; + + var scriptService = WorkflowScriptService.Instance; + + foreach (var workflow in workflows) + { + try + { + var count = scriptService.PreloadCompiledScripts(workflow.Id); + totalLoaded += count; + } + catch (Exception ex) + { + Log.Debug(ex, "[WorkflowStorageService] Error preloading compiled scripts for workflow {Id}", workflow.Id); + } + } + + if (totalLoaded > 0) + Log.Information("[WorkflowStorageService] Preloaded {Count} compiled scripts for {WfCount} workflows", + totalLoaded, workflows.Count); + + return totalLoaded; + } + private void EnsureDirectoryExists() { if (!Directory.Exists(_storageDirectory)) diff --git a/KitX Standard b/KitX Standard index aeb466d2..8f2ee179 160000 --- a/KitX Standard +++ b/KitX Standard @@ -1 +1 @@ -Subproject commit aeb466d2b996d865204cd5d0d6d1f6cbde789df5 +Subproject commit 8f2ee17923cc1d54025520ab268e351e181289e2 From 34a69de27aa48b5cf02a9ba46805ecbab71b8f84 Mon Sep 17 00:00:00 2001 From: StarInk Date: Sat, 18 Apr 2026 20:35:13 +0200 Subject: [PATCH 057/110] =?UTF-8?q?=F0=9F=92=BE=20Feat(Core):=20Implement?= =?UTF-8?q?=20cross-device=20plugin=20invocation=20and=20device=20discover?= =?UTF-8?q?y=20functions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Enhanced CFGBuilderFromBlueprint to support cross-device plugin calls using PluginCallWithTarget. - Updated FormattedBlockScript to include new statement kinds for PluginCallWithTarget and TryGetDevice. - Modified NodeBuilder to handle PluginCallWithTarget and extract necessary arguments. - Introduced RealPluginManager methods for remote plugin invocation and device token management. - Added DeviceHttpClient for HTTP communication with remote devices. - Created PluginCallFunction and PluginCallWithTargetFunction for local and remote plugin calls. - Implemented TryGetDeviceFunction to retrieve DeviceInfo based on device name. - Updated BlockScriptExecutionGlobals to support new plugin call functionalities. --- .../DI/CoreServiceCollectionExtensions.cs | 4 + .../KitX.Core/Device/DeviceHttpClient.cs | 129 +++++++ .../KitX.Core/Device/DevicesServer.cs | 265 +++++++++++++ .../KitX.Core/Device/PluginsServer.cs | 6 +- .../BlockScriptExecutionGlobals.cs | 141 ++----- .../BuiltinFunctions/BranchFunction.cs | 161 ++++---- .../BuiltinFunctions/BreakFunction.cs | 1 - .../BuiltinFunctions/FlipFunction.cs | 197 +++++----- .../BuiltinFunctions/GetFunction.cs | 1 - .../BuiltinFunctions/LoopFunction.cs | 175 +++++---- .../BuiltinFunctions/PauseFunction.cs | 108 +++--- .../BuiltinFunctions/PluginCallFunction.cs | 105 +++++ .../PluginCallWithTargetFunction.cs | 162 ++++++++ .../BuiltinFunctions/PrintFunction.cs | 109 +++--- .../BuiltinFunctions/SetFunction.cs | 165 ++++---- .../BuiltinFunctions/ToLoopCondFunction.cs | 191 ++++----- .../BuiltinFunctions/TryGetDeviceFunction.cs | 192 ++++++++++ .../IBuiltinFunctionDefinition.cs | 8 - .../BlockScripting/ScriptAssemblyCompiler.cs | 103 ++++- .../Blueprint/CFG/CFGBuilderFromBlueprint.cs | 21 +- .../Pipeline/FormattedBlockScript.cs | 9 + .../Blueprint/Pipeline/NodeBuilder.cs | 22 +- .../KitX.Core/Workflow/RealPluginManager.cs | 362 +++++++++++++++++- KitX Clients/KitX Dashboard | 2 +- KitX Standard | 2 +- 25 files changed, 1989 insertions(+), 652 deletions(-) create mode 100644 KitX Clients/KitX Core/KitX.Core/Device/DeviceHttpClient.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PluginCallFunction.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PluginCallWithTargetFunction.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/TryGetDeviceFunction.cs diff --git a/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs b/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs index a73ccdbf..a1216390 100644 --- a/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs +++ b/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs @@ -148,6 +148,10 @@ public static IServiceCollection AddCoreServices(this IServiceCollection service return service; }); + // Phase 5: Device HTTP Client (for cross-device plugin invocation) + Log.Information("Registering IDeviceHttpClient..."); + services.AddSingleton(); + // Phase 5: Announcement Service Log.Information("Registering IAnnouncementService..."); services.AddSingleton(provider => diff --git a/KitX Clients/KitX Core/KitX.Core/Device/DeviceHttpClient.cs b/KitX Clients/KitX Core/KitX.Core/Device/DeviceHttpClient.cs new file mode 100644 index 00000000..8368bc8e --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Device/DeviceHttpClient.cs @@ -0,0 +1,129 @@ +using System; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using KitX.Shared.CSharp.Device; +using KitX.Shared.CSharp.WebCommand; +using Serilog; + +namespace KitX.Core.Device; + +/// +/// Interface for device HTTP client — sends requests to remote DevicesServer instances. +/// Used for cross-device plugin invocation via the /Api/V1/Plugin/Invoke endpoint. +/// +public interface IDeviceHttpClient +{ + /// + /// Invokes a plugin method on a remote device via HTTP POST to /Api/V1/Plugin/Invoke. + /// + /// Target device info (contains IPv4 and DevicesServerPort) + /// Valid session token for the target device + /// The Request object to send + /// Cancellation token + /// HTTP response from remote device, or null on network error + Task InvokePluginAsync( + DeviceInfo targetDevice, + string token, + Request request, + CancellationToken ct = default); +} + +/// +/// Device HTTP client implementation. +/// Sends plugin invoke requests to remote DevicesServer over HTTP. +/// Protocol compatible with legacy PluginControllerExtensions.RemoteInvoke. +/// +public class DeviceHttpClient : IDeviceHttpClient +{ + private static readonly HttpClient _httpClient = new() + { + Timeout = TimeSpan.FromSeconds(35) + }; + + /// + /// JSON serializer options (compatible with legacy KitX network protocol) + /// + private static readonly JsonSerializerOptions SerializerOptions = new() + { + WriteIndented = false, + IncludeFields = true, + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + /// + /// Invokes a plugin method on a remote device. + /// + public async Task InvokePluginAsync( + DeviceInfo targetDevice, + string token, + Request request, + CancellationToken ct = default) + { + if (targetDevice?.Device == null) + { + Log.Warning("[DeviceHttpClient] InvokePluginAsync called with null targetDevice"); + return null; + } + + var ipv4 = targetDevice.Device.IPv4; + var port = targetDevice.DevicesServerPort; + + if (string.IsNullOrEmpty(ipv4) || port <= 0) + { + Log.Warning("[DeviceHttpClient] Invalid device address: IPv4={IPv4}, Port={Port}", + ipv4, port); + return null; + } + + try + { + // Step 1: Serialize Request to JSON + var requestJson = JsonSerializer.Serialize(request, SerializerOptions); + + // Step 2: Wrap in base64 (legacy protocol format) + var requestJsonBytes = Encoding.UTF8.GetBytes(requestJson); + var requestJsonBase64 = Convert.ToBase64String(requestJsonBytes); + var wrappedJson = JsonSerializer.Serialize(requestJsonBase64); + + // Step 3: Build URL + var url = $"http://{ipv4}:{port}/Api/V1/Plugin/Invoke?token={token}"; + + Log.Debug("[DeviceHttpClient] Sending plugin invoke to {Url}, Target={Target}, Function={Function}", + url, request.Target, request.Content); + + // Step 4: Send HTTP POST + var response = await _httpClient.PostAsync( + url, + new StringContent(wrappedJson, Encoding.UTF8, "application/json"), + ct); + + Log.Debug("[DeviceHttpClient] Received response from {Url}: Status={Status}", + url, response.StatusCode); + + return response; + } + catch (HttpRequestException ex) + { + Log.Error(ex, "[DeviceHttpClient] HTTP error invoking plugin on device {Device}:{Port}", + ipv4, port); + return null; + } + catch (TaskCanceledException ex) when (ex.CancellationToken != ct) + { + Log.Error(ex, "[DeviceHttpClient] Timeout invoking plugin on device {Device}:{Port}", + ipv4, port); + return null; + } + catch (Exception ex) + { + Log.Error(ex, "[DeviceHttpClient] Unexpected error invoking plugin on device {Device}:{Port}", + ipv4, port); + return null; + } + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Device/DevicesServer.cs b/KitX Clients/KitX Core/KitX.Core/Device/DevicesServer.cs index c65b1c8f..44305e17 100644 --- a/KitX Clients/KitX Core/KitX.Core/Device/DevicesServer.cs +++ b/KitX Clients/KitX Core/KitX.Core/Device/DevicesServer.cs @@ -1,15 +1,21 @@ using System; using System.Collections.Generic; +using System.Collections.Concurrent; using System.IO; using System.Linq; using System.Text; using System.Text.Json; +using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; using KitX.Core.Contract.Device; +using KitX.Core.Contract.Plugin; using KitX.Core.Event; using KitX.Core.Security; using KitX.Shared.CSharp.Device; +using KitX.Shared.CSharp.Security; +using KitX.Shared.CSharp.WebCommand; +using KitX.Shared.CSharp.WebCommand.Infos; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting.Server; @@ -48,6 +54,22 @@ public class DevicesServer : IDeviceServer /// private string? _exchangeDeviceKeyCode; + /// + /// JSON serializer options for network protocol (compatible with legacy KitX) + /// + private static readonly JsonSerializerOptions SerializerOptions = new() + { + WriteIndented = true, + IncludeFields = true, + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + /// + /// Pending plugin invoke responses, keyed by RequestId, for correlating async responses + /// + private readonly ConcurrentDictionary> _pendingPluginResponses = new(); + /// /// Gets the service status /// @@ -146,6 +168,23 @@ public IDeviceServer Run() break; } }); + + // Plugin controller endpoints (旧架构 API 标准) + // POST /Api/V1/Plugin/Invoke?token=xxx + endpoints.MapPost("/Api/V1/Plugin/{action}", async context => + { + var action = context.Request.RouteValues["action"]?.ToString(); + switch (action) + { + case "Invoke": + await HandlePluginInvokeAsync(context); + break; + default: + context.Response.StatusCode = 404; + await context.Response.WriteAsync("Not found"); + break; + } + }); }); }) .Build(); @@ -754,6 +793,232 @@ private async System.Threading.Tasks.Task HandleConnectLegacyAsync(HttpContext c await context.Response.WriteAsync($"Error: {ex.Message}"); } } + + /// + /// Handles Plugin/Invoke request — routes plugin command to local PluginsServer connection. + /// Protocol compatible with legacy PluginController.Invoke. + /// POST /Api/V1/Plugin/Invoke?token=xxx + /// + private async Task HandlePluginInvokeAsync(HttpContext context) + { + const string location = $"{nameof(DevicesServer)}.{nameof(HandlePluginInvokeAsync)}"; + + try + { + // 1. Validate token + var token = context.Request.Query["token"].ToString(); + if (string.IsNullOrEmpty(token)) + { + Log.Warning("[{Location}] Missing token in Plugin/Invoke request", location); + context.Response.StatusCode = 400; + await context.Response.WriteAsync("Missing token parameter"); + return; + } + + if (!IsDeviceTokenExist(token)) + { + Log.Warning("[{Location}] Invalid token in Plugin/Invoke request", location); + context.Response.StatusCode = 401; + await context.Response.WriteAsync("You should connect to this device first."); + return; + } + + // 2. Read base64-wrapped request JSON from body + using var reader = new StreamReader(context.Request.Body); + var body = await reader.ReadToEndAsync(); + if (string.IsNullOrEmpty(body)) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync("Missing request body"); + return; + } + + string requestJson; + try + { + // Legacy format: body is a JSON string containing base64(data) + var wrapped = JsonSerializer.Deserialize(body, SerializerOptions); + if (string.IsNullOrEmpty(wrapped)) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync("Invalid request body format"); + return; + } + requestJson = Encoding.UTF8.GetString(Convert.FromBase64String(wrapped)); + } + catch (Exception ex) + { + Log.Warning(ex, "[{Location}] Failed to decode request body", location); + context.Response.StatusCode = 400; + await context.Response.WriteAsync("Invalid request body encoding"); + return; + } + + // 3. Deserialize Request + var request = JsonSerializer.Deserialize(requestJson, SerializerOptions); + if (request == null) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync("Invalid request format"); + return; + } + + // 4. Validate request.Target + var senderLocator = SearchDeviceByToken(token); + if (request.Target == null) + { + Log.Warning("[{Location}] Plugin/Invoke request has no target", location); + context.Response.StatusCode = 400; + await context.Response.WriteAsync("Provide target field please."); + return; + } + + if (!request.Target.IsSameDevice(senderLocator ?? new DeviceLocator())) + { + Log.Warning("[{Location}] Plugin/Invoke request target mismatch: {Target} vs {Sender}", + location, request.Target, senderLocator); + context.Response.StatusCode = 403; + await context.Response.WriteAsync("Please send to actual target."); + return; + } + + // 5. Handle content decryption if encrypted (simplified — full encryption handled by SecurityManager) + var content = request.Content; + if (request.EncryptionInfo?.IsEncrypted == true) + { + content = DecryptContent(request, token); + } + + // 6. Deserialize Command + var command = JsonSerializer.Deserialize(content, SerializerOptions); + if (command.Equals(default(Command))) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync("Invalid command format"); + return; + } + + // 7. Find local plugin connection by PluginConnectionId + var connector = PluginsServer.Instance.FindConnection(command.PluginConnectionId); + if (connector == null) + { + Log.Warning("[{Location}] Plugin connection not found: {ConnectionId}", + location, command.PluginConnectionId); + context.Response.StatusCode = 404; + await context.Response.WriteAsync("Plugin connection not found"); + return; + } + + // 8. Generate RequestId and set up async response wait + var requestId = Guid.NewGuid().ToString(); + command.Tags ??= new(); + command.Tags["RequestId"] = requestId; + + var tcs = new TaskCompletionSource(); + + // Subscribe to plugin response + void OnResponse(object? sender, PluginResponseEventArgs e) + { + if (e.RequestId == requestId) + { + PluginsServer.Instance.PluginResponse -= OnResponse; + _pendingPluginResponses.TryRemove(requestId, out _); + tcs.TrySetResult(e.Content); + } + } + PluginsServer.Instance.PluginResponse += OnResponse; + _pendingPluginResponses[requestId] = tcs; + + // 9. Build the request to send to plugin (manual copy since Request is class not record) + var updatedRequest = new Request + { + Type = request.Type, + Version = request.Version, + Sender = request.Sender, + Target = request.Target, + EncryptionInfo = request.EncryptionInfo, + CompressionInfo = request.CompressionInfo, + Content = content + }; + var pluginRequestJson = JsonSerializer.Serialize(updatedRequest, SerializerOptions); + + // 10. Send to plugin via local PluginsServer + connector.Send(pluginRequestJson); + + Log.Information("[{Location}] Forwarded plugin invoke to {PluginId}, RequestId: {RequestId}", + location, command.PluginConnectionId, requestId); + + // 11. Wait for response with 30s timeout + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + try + { + var result = await tcs.Task.WaitAsync(cts.Token); + context.Response.ContentType = "application/json"; + await context.Response.WriteAsync(result); + Log.Information("[{Location}] Plugin invoke completed, RequestId: {RequestId}", location, requestId); + } + catch (TimeoutException) + { + Log.Warning("[{Location}] Plugin invoke timed out, RequestId: {RequestId}", location, requestId); + _pendingPluginResponses.TryRemove(requestId, out _); + PluginsServer.Instance.PluginResponse -= OnResponse; + context.Response.StatusCode = 504; + await context.Response.WriteAsync("Plugin invocation timed out"); + } + catch (OperationCanceledException) + { + Log.Warning("[{Location}] Plugin invoke cancelled, RequestId: {RequestId}", location, requestId); + _pendingPluginResponses.TryRemove(requestId, out _); + PluginsServer.Instance.PluginResponse -= OnResponse; + context.Response.StatusCode = 499; + await context.Response.WriteAsync("Plugin invocation cancelled"); + } + } + catch (Exception ex) + { + Log.Error(ex, "[{Location}] Error handling Plugin/Invoke request", location); + context.Response.StatusCode = 500; + await context.Response.WriteAsync($"Error: {ex.Message}"); + } + } + + /// + /// Decrypts request content based on encryption method. + /// Simplified implementation — full RSA/AES decryption delegated to SecurityManager. + /// + private string DecryptContent(Request request, string token) + { + if (request.EncryptionInfo == null || !request.EncryptionInfo.IsEncrypted) + return request.Content; + + var content = request.Content; + + if (request.EncryptionInfo.EncryptionMethod == EncryptionMethods.RSA) + { + var device = SearchDeviceByToken(token); + if (device != null) + { + var key = SecurityManager.Instance.SearchDeviceKey(device); + if (key != null) + { + try + { + var encryptedContent = JsonSerializer.Deserialize(content, SerializerOptions); + if (encryptedContent != null) + { + content = SecurityManager.Instance.RsaDecryptContent(key, encryptedContent) ?? content; + } + } + catch (Exception ex) + { + Log.Warning(ex, "[DevicesServer] Failed to RSA-decrypt plugin invoke content"); + } + } + } + } + + return content; + } } /// diff --git a/KitX Clients/KitX Core/KitX.Core/Device/PluginsServer.cs b/KitX Clients/KitX Core/KitX.Core/Device/PluginsServer.cs index 36c0cd38..fb27c191 100644 --- a/KitX Clients/KitX Core/KitX.Core/Device/PluginsServer.cs +++ b/KitX Clients/KitX Core/KitX.Core/Device/PluginsServer.cs @@ -141,14 +141,14 @@ public IPluginServer Run() if (ConstantTable.PluginsServerPort <= 0) { currentPort = startPort + retryCount; - // Use 127.0.0.1 instead of 0.0.0.0 to avoid permission issues - _server = new WebSocketServer($"ws://127.0.0.1:{currentPort}"); + // Accept connections on all network interfaces (legacy protocol compatible) + _server = new WebSocketServer($"ws://0.0.0.0:{currentPort}"); } else { // Use configured port currentPort = ConstantTable.PluginsServerPort; - _server = new WebSocketServer($"ws://127.0.0.1:{currentPort}"); + _server = new WebSocketServer($"ws://0.0.0.0:{currentPort}"); } _server!.Start(socket => diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutionGlobals.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutionGlobals.cs index 44e54e54..1a714da6 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutionGlobals.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutionGlobals.cs @@ -1,6 +1,5 @@ -using System; +using System; using System.Collections.Generic; -using System.Threading; using KitX.Core.Contract.Workflow; using KitX.Core.Workflow; using Serilog; @@ -8,48 +7,48 @@ namespace KitX.Core.Workflow.BlockScripting; /// -/// Script globals for block script execution - provides access to built-in functions and collects output +/// Script globals for block script execution — 仅保留核心手脚架。 +/// 各 builtin 函数的运行时方法已迁移到各自的 partial class 文件中。 /// -public class BlockScriptExecutionGlobals +public partial class BlockScriptExecutionGlobals { + // ─── 核心字段 ─────────────────────────────────────────────── + private readonly BlockScopeManager _scopeManager; private readonly List _output; private readonly Dictionary _variables = new(); private readonly IPluginManager? _pluginManager; + // ─── 内置属性 ─────────────────────────────────────────────── + /// /// NextBlock 内置变量 - 设置后执行器会跳转到指定块 - /// 每个块执行前会被重置为 null /// - public string? NextBlock { get; set; } = null; + public string? NextBlock { get; set; } /// /// Number of blocks executed so far in the current run. - /// Incremented at the start of each block's switch case in the compiled assembly path. /// public int ExecutedBlockCount { get; set; } - /// - /// Creates script globals - /// + // ─── 构造 ─────────────────────────────────────────────── + public BlockScriptExecutionGlobals(BlockScopeManager scopeManager, List output) { _scopeManager = scopeManager; _output = output; } - /// - /// Creates script globals with plugin manager support - /// public BlockScriptExecutionGlobals(BlockScopeManager scopeManager, List output, IPluginManager? pluginManager) : this(scopeManager, output) { _pluginManager = pluginManager; } + // ─── 变量访问 ─────────────────────────────────────────────── + /// - /// Gets a variable value - called by CSharpScript when accessing unknown properties - /// Returns dynamic to allow implicit conversion to target variable types + /// Gets a variable value dynamically (CSharpScript path). /// public dynamic Get(string name) { @@ -61,9 +60,7 @@ public dynamic Get(string name) } /// - /// Gets a variable value typed as . - /// Used by the compiled assembly path to avoid dynamic binding overhead. - /// Performs standard unboxing/conversion, no Microsoft.CSharp.RuntimeBinder needed. + /// Gets a variable value typed as T (compiled assembly path). /// public T? Get(string name) { @@ -75,7 +72,7 @@ public dynamic Get(string name) } /// - /// Sets a variable value + /// Sets a variable value in local scope. /// public void Set(string name, object? value) { @@ -89,7 +86,7 @@ public void Set(string name, object? value) } /// - /// Sets a variable value in global scope + /// Sets a variable value in global scope. /// public void SetGlobalVariable(string name, object? value) { @@ -103,7 +100,7 @@ public void SetGlobalVariable(string name, object? value) } /// - /// Resets NextBlock to null (called before each block execution) + /// Resets NextBlock to null (called before each block execution). /// public void ResetNextBlock() { @@ -111,112 +108,18 @@ public void ResetNextBlock() } /// - /// Gets all variables for debugging + /// Gets all variables for debugging. /// public Dictionary GetAllVariables() => new(_variables); - /// - /// Condition branch - sets NextBlock and returns the target block name - /// - public string? Branch(bool condition, string trueBlock, string falseBlock) - { - NextBlock = condition ? trueBlock : falseBlock; - return NextBlock; - } - - /// - /// Loop while condition is true (three-argument syntax) - /// - public string? Loop(bool condition, string trueBlock, string falseBlock) - { - NextBlock = condition ? trueBlock : falseBlock; - return NextBlock; - } - - /// - /// Print a value - /// - public void Print(object? value) - { - var str = value?.ToString() ?? "null"; - _output.Add(str); - WorkflowOutput.WriteLine(value); - } - - /// - /// Pause execution - /// - public void Pause(int milliseconds) - { - Thread.Sleep(milliseconds); - } + // ─── 运行状态重置 ─────────────────────────────────────────────── /// - /// Flip counter — tracks odd/even execution state. Reset on each run from Entry. - /// - private int _flipCounter = 0; - - /// - /// Resets run-level state (called when execution starts from Entry node) + /// Resets run-level state (called when execution starts from Entry node). /// public void ResetRunState() { - _flipCounter = 0; + ResetFlipCounter(); ExecutedBlockCount = 0; } - - /// - /// Flip — alternating control flow. Routes to outputA on odd calls, outputB on even calls. - /// - public string? Flip(string outputA, string outputB) - { - _flipCounter++; - NextBlock = (_flipCounter % 2 == 1) ? outputA : outputB; - return NextBlock; - } - - /// - /// ToLoopCond - marks the end of a loop body and returns to the loop condition block - /// - public string? ToLoopCond(string parentBlockName) - { - NextBlock = parentBlockName; - return NextBlock; - } - - /// - /// 调用插件函数。所有分发策略(类型化调用、fire-and-forget vs 同步等待) - /// 由 RealPluginManager.CallAuto() 内部自动完成,调用方无需关心。 - /// - public object? PluginCall(string pluginName, string methodName, params object[] args) - { - if (_pluginManager == null) - { - Log.Warning("[BlockScriptGlobals] PluginCall: no plugin manager available, " + - "cannot call {PluginName}.{MethodName}", pluginName, methodName); - return null; - } - - var callInfo = new PluginCallInfo - { - PluginName = pluginName, - MethodName = methodName, - Parameters = args ?? Array.Empty() - }; - - try - { - if (_pluginManager is RealPluginManager realManager) - return realManager.CallAuto(callInfo); - - // Fallback for other IPluginManager implementations - return _pluginManager.Call(callInfo); - } - catch (Exception ex) - { - Log.Error(ex, "[BlockScriptGlobals] PluginCall failed: {PluginName}.{MethodName}", - pluginName, methodName); - return null; - } - } } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/BranchFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/BranchFunction.cs index 4cdf27c9..33770d02 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/BranchFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/BranchFunction.cs @@ -6,95 +6,108 @@ using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Pins; -namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions; - -/// -/// Branch 内置函数 — 条件分支控制流。 -/// -public class BranchFunction : IBuiltinFunctionDefinition +namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions { - public string FunctionName => "Branch"; - public string DisplayName => "Branch"; - public bool IsFlowControl => true; - public bool IsNonExtractable => false; - public BlueprintNodeType? LegacyNodeType => BlueprintNodeType.Branch; - public bool IsBlockTerminator => true; - public FormattedStatementKind StatementKind => FormattedStatementKind.Branch; - public double NodeWidth => 120; - public double NodeHeight => 80; + /// + /// Branch 内置函数 — 条件分支控制流。 + /// + public class BranchFunction : IBuiltinFunctionDefinition + { + public string FunctionName => "Branch"; + public string DisplayName => "Branch"; + public bool IsFlowControl => true; + public bool IsNonExtractable => false; + public BlueprintNodeType? LegacyNodeType => BlueprintNodeType.Branch; + public bool IsBlockTerminator => true; + public FormattedStatementKind StatementKind => FormattedStatementKind.Branch; + public double NodeWidth => 120; + public double NodeHeight => 80; - public IReadOnlyList InputPins => [ - new("Exec", PinType.Execution, 30), - new("Condition", PinType.Boolean, 50) - ]; + public IReadOnlyList InputPins => [ + new("Exec", PinType.Execution, 30), + new("Condition", PinType.Boolean, 50) + ]; - public IReadOnlyList OutputPins => [ - new("True", PinType.Execution, 30), - new("False", PinType.Execution, 50) - ]; + public IReadOnlyList OutputPins => [ + new("True", PinType.Execution, 30), + new("False", PinType.Execution, 50) + ]; - public string? GetExecutionMethodBody() => null; + public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) + { + var args = invoke.ArgumentList.Arguments; + var stmt = new FlowControlStatement + { + LineNumber = lineNumber, + SourceCode = exprText ?? invoke.ToFullString(), + ControlType = FlowControlType.Branch + }; + if (args.Count >= 1) stmt.ConditionExpression = args[0].Expression.ToString(); + if (args.Count >= 2) stmt.TrueBlockName = GetStringLiteral(args[1].Expression); + if (args.Count >= 3) stmt.FalseBlockName = GetStringLiteral(args[2].Expression); + return stmt; + } - public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) - { - var args = invoke.ArgumentList.Arguments; - var stmt = new FlowControlStatement + public List FormatInvocation( + InvocationExpressionSyntax invoke, string blockName, + PipelineContext context, string? assignedVar) { - LineNumber = lineNumber, - SourceCode = exprText ?? invoke.ToFullString(), - ControlType = FlowControlType.Branch - }; - if (args.Count >= 1) stmt.ConditionExpression = args[0].Expression.ToString(); - if (args.Count >= 2) stmt.TrueBlockName = GetStringLiteral(args[1].Expression); - if (args.Count >= 3) stmt.FalseBlockName = GetStringLiteral(args[2].Expression); - return stmt; - } + return []; + } - public List FormatInvocation( - InvocationExpressionSyntax invoke, string blockName, - PipelineContext context, string? assignedVar) - { - // Branch is handled through FormatFlowControl, not FormatInvocation. - return []; - } + public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) => node; - public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) => node; + public void OnNodeCreated(BlueprintNode node, FormattedStatement stmt, PipelineContext context) + { + if (!string.IsNullOrEmpty(stmt.TrueBlockName) || !string.IsNullOrEmpty(stmt.FalseBlockName)) + { + var arms = new List<(string PinName, string TargetBlockName)>(); + if (!string.IsNullOrEmpty(stmt.TrueBlockName)) + arms.Add(("True", stmt.TrueBlockName)); + if (!string.IsNullOrEmpty(stmt.FalseBlockName)) + arms.Add(("False", stmt.FalseBlockName)); + context.DeferredEdges.Add(new DeferredControlFlowEdge + { + SourceStatementId = stmt.StatementId, + Arms = arms, + }); + } + } - public void OnNodeCreated(BlueprintNode node, FormattedStatement stmt, PipelineContext context) - { - if (!string.IsNullOrEmpty(stmt.TrueBlockName) || !string.IsNullOrEmpty(stmt.FalseBlockName)) + public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) { - var arms = new List<(string PinName, string TargetBlockName)>(); - if (!string.IsNullOrEmpty(stmt.TrueBlockName)) - arms.Add(("True", stmt.TrueBlockName)); - if (!string.IsNullOrEmpty(stmt.FalseBlockName)) - arms.Add(("False", stmt.FalseBlockName)); - context.DeferredEdges.Add(new DeferredControlFlowEdge + var condition = helper.GetInputValue(node, "Condition"); + return new FlowControlStatement { - SourceStatementId = stmt.StatementId, - Arms = arms, - }); + ControlType = FlowControlType.Branch, + ConditionExpression = condition, + SourceCode = $"Branch({condition}, \"\", \"\");", + LineNumber = 1 + }; } + + public IEnumerable GetOutputArms() => + [ + new() { PinName = "True", IsLoopback = false }, + new() { PinName = "False", IsLoopback = false } + ]; + + private static string GetStringLiteral(ExpressionSyntax expr) => + expr is LiteralExpressionSyntax lit ? lit.Token.ValueText : expr.ToString().Trim('"'); } +} - public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) +namespace KitX.Core.Workflow.BlockScripting +{ + public partial class BlockScriptExecutionGlobals { - var condition = helper.GetInputValue(node, "Condition"); - return new FlowControlStatement + /// + /// Condition branch - sets NextBlock and returns the target block name. + /// + public string? Branch(bool condition, string trueBlock, string falseBlock) { - ControlType = FlowControlType.Branch, - ConditionExpression = condition, - SourceCode = $"Branch({condition}, \"\", \"\");", - LineNumber = 1 - }; + NextBlock = condition ? trueBlock : falseBlock; + return NextBlock; + } } - - public IEnumerable GetOutputArms() => - [ - new() { PinName = "True", IsLoopback = false }, - new() { PinName = "False", IsLoopback = false } - ]; - - private static string GetStringLiteral(ExpressionSyntax expr) => - expr is LiteralExpressionSyntax lit ? lit.Token.ValueText : expr.ToString().Trim('"'); } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/BreakFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/BreakFunction.cs index b3a022a8..7426599e 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/BreakFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/BreakFunction.cs @@ -27,7 +27,6 @@ public class BreakFunction : IBuiltinFunctionDefinition public IReadOnlyList OutputPins => []; - public string? GetExecutionMethodBody() => null; public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/FlipFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/FlipFunction.cs index bfcfd0cd..88bdf06a 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/FlipFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/FlipFunction.cs @@ -4,114 +4,129 @@ using KitX.Core.Workflow.Blueprint; using KitX.Core.Workflow.Blueprint.Pipeline; -namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions; - -/// -/// Flip 内置函数 — 交替路由控制流。每次执行时交替选择两个输出分支之一。 -/// 第一次执行走 A(奇数次),第二次走 B(偶数次),循环往复。 -/// 运行级状态:从 Entry 节点重新开始运行时重置计数器。 -/// -/// BlockScript 语法:Flip("blockA", "blockB") -/// -public class FlipFunction : IBuiltinFunctionDefinition +namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions { - public string FunctionName => "Flip"; - public string DisplayName => "Flip"; - public bool IsFlowControl => true; - public bool IsNonExtractable => true; - public bool IsBlockTerminator => true; - public FormattedStatementKind StatementKind => FormattedStatementKind.Branch; - public double NodeWidth => 120; - public double NodeHeight => 80; - - public IReadOnlyList InputPins => [ - new("Exec", PinType.Execution, 30) - ]; + /// + /// Flip 内置函数 — 交替路由控制流。每次执行时交替选择两个输出分支之一。 + /// 第一次执行走 A(奇数次),第二次走 B(偶数次),循环往复。 + /// 运行级状态:从 Entry 节点重新开始运行时重置计数器。 + /// + /// BlockScript 语法:Flip("blockA", "blockB") + /// + public class FlipFunction : IBuiltinFunctionDefinition + { + public string FunctionName => "Flip"; + public string DisplayName => "Flip"; + public bool IsFlowControl => true; + public bool IsNonExtractable => true; + public bool IsBlockTerminator => true; + public FormattedStatementKind StatementKind => FormattedStatementKind.Branch; + public double NodeWidth => 120; + public double NodeHeight => 80; - public IReadOnlyList OutputPins => [ - new("A", PinType.Execution, 30), - new("B", PinType.Execution, 50) - ]; + public IReadOnlyList InputPins => [ + new("Exec", PinType.Execution, 30) + ]; - public string? GetExecutionMethodBody() => """ - private int _flipCounter = 0; + public IReadOnlyList OutputPins => [ + new("A", PinType.Execution, 30), + new("B", PinType.Execution, 50) + ]; - public void ResetRunState() => _flipCounter = 0; - public string? Flip(string outputA, string outputB) + public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) { - _flipCounter++; - NextBlock = (_flipCounter % 2 == 1) ? outputA : outputB; - return NextBlock; + var args = invoke.ArgumentList.Arguments; + var stmt = new FlowControlStatement + { + LineNumber = lineNumber, + SourceCode = exprText ?? invoke.ToFullString(), + ControlType = FlowControlType.Branch // Reuse Branch type for cross-block routing + }; + if (args.Count >= 1) stmt.TrueBlockName = GetStringLiteral(args[0].Expression); + if (args.Count >= 2) stmt.FalseBlockName = GetStringLiteral(args[1].Expression); + return stmt; } - """; - public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) - { - var args = invoke.ArgumentList.Arguments; - var stmt = new FlowControlStatement + public List FormatInvocation( + InvocationExpressionSyntax invoke, string blockName, + PipelineContext context, string? assignedVar) { - LineNumber = lineNumber, - SourceCode = exprText ?? invoke.ToFullString(), - ControlType = FlowControlType.Branch // Reuse Branch type for cross-block routing - }; - if (args.Count >= 1) stmt.TrueBlockName = GetStringLiteral(args[0].Expression); - if (args.Count >= 2) stmt.FalseBlockName = GetStringLiteral(args[1].Expression); - return stmt; - } + var args = invoke.ArgumentList.Arguments; + var trueBlock = args.Count >= 1 ? GetStringLiteral(args[0].Expression) : ""; + var falseBlock = args.Count >= 2 ? GetStringLiteral(args[1].Expression) : ""; - public List FormatInvocation( - InvocationExpressionSyntax invoke, string blockName, - PipelineContext context, string? assignedVar) - { - var args = invoke.ArgumentList.Arguments; - var trueBlock = args.Count >= 1 ? GetStringLiteral(args[0].Expression) : ""; - var falseBlock = args.Count >= 2 ? GetStringLiteral(args[1].Expression) : ""; + return [new FormattedStatement + { + BlockName = blockName, + Kind = FormattedStatementKind.Branch, // Reuse Branch kind for pipeline routing + FunctionName = FunctionName, + TrueBlockName = trueBlock, + FalseBlockName = falseBlock, + OriginalExpression = invoke.ToString(), + SourceLine = 0, + }]; + } - return [new FormattedStatement - { - BlockName = blockName, - Kind = FormattedStatementKind.Branch, // Reuse Branch kind for pipeline routing - FunctionName = FunctionName, - TrueBlockName = trueBlock, - FalseBlockName = falseBlock, - OriginalExpression = invoke.ToString(), - SourceLine = 0, - }]; - } + public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) => node; - public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) => node; + public void OnNodeCreated(BlueprintNode node, FormattedStatement stmt, PipelineContext context) + { + var arms = new List<(string PinName, string TargetBlockName)>(); + if (!string.IsNullOrEmpty(stmt.TrueBlockName)) + arms.Add(("A", stmt.TrueBlockName)); + if (!string.IsNullOrEmpty(stmt.FalseBlockName)) + arms.Add(("B", stmt.FalseBlockName)); + context.DeferredEdges.Add(new DeferredControlFlowEdge + { + SourceStatementId = stmt.StatementId, + Arms = arms, + }); + } - public void OnNodeCreated(BlueprintNode node, FormattedStatement stmt, PipelineContext context) - { - var arms = new List<(string PinName, string TargetBlockName)>(); - if (!string.IsNullOrEmpty(stmt.TrueBlockName)) - arms.Add(("A", stmt.TrueBlockName)); - if (!string.IsNullOrEmpty(stmt.FalseBlockName)) - arms.Add(("B", stmt.FalseBlockName)); - context.DeferredEdges.Add(new DeferredControlFlowEdge + public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) { - SourceStatementId = stmt.StatementId, - Arms = arms, - }); + return new ExpressionStatement + { + Expression = "Flip(\"\", \"\")", + SourceCode = "Flip(\"\", \"\");", + LineNumber = 1 + }; + } + + public IEnumerable GetOutputArms() => + [ + new() { PinName = "A", IsLoopback = false }, + new() { PinName = "B", IsLoopback = false } + ]; + + private static string GetStringLiteral(ExpressionSyntax expr) => + expr is LiteralExpressionSyntax lit ? lit.Token.ValueText : expr.ToString().Trim('"'); } +} - public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) +namespace KitX.Core.Workflow.BlockScripting +{ + // ────────────────────────────────────────────── + // Partial class — Flip 的运行时方法和状态 + // ────────────────────────────────────────────── + public partial class BlockScriptExecutionGlobals { - return new ExpressionStatement - { - Expression = "Flip(\"\", \"\")", - SourceCode = "Flip(\"\", \"\");", - LineNumber = 1 - }; - } + private static int _flipCounter = 0; - public IEnumerable GetOutputArms() => - [ - new() { PinName = "A", IsLoopback = false }, - new() { PinName = "B", IsLoopback = false } - ]; + /// + /// Flip — alternating control flow. + /// + public string? Flip(string outputA, string outputB) + { + _flipCounter++; + NextBlock = (_flipCounter % 2 == 1) ? outputA : outputB; + return NextBlock; + } - private static string GetStringLiteral(ExpressionSyntax expr) => - expr is LiteralExpressionSyntax lit ? lit.Token.ValueText : expr.ToString().Trim('"'); + /// + /// Resets the flip counter. Called by ResetRunState(). + /// + internal static void ResetFlipCounter() => _flipCounter = 0; + } } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/GetFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/GetFunction.cs index d91c5d93..959cf133 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/GetFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/GetFunction.cs @@ -29,7 +29,6 @@ public class GetFunction : IBuiltinFunctionDefinition new("Value", PinType.Any, 40) ]; - public string? GetExecutionMethodBody() => null; public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/LoopFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/LoopFunction.cs index 1f80e94f..d5e940b9 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/LoopFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/LoopFunction.cs @@ -6,104 +6,113 @@ using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Pins; -namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions; - -/// -/// Loop 内置函数 — 循环控制流。 -/// BlockScript 语法:Loop(condition, "loopBodyBlock", "loopEndBlock") -/// -public class LoopFunction : IBuiltinFunctionDefinition +namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions { - public string FunctionName => "Loop"; - public string DisplayName => "Loop"; - public bool IsFlowControl => true; - public bool IsNonExtractable => false; - public BlueprintNodeType? LegacyNodeType => BlueprintNodeType.Loop; - public bool IsBlockTerminator => true; - public FormattedStatementKind StatementKind => FormattedStatementKind.Loop; - public double NodeWidth => 120; - public double NodeHeight => 80; - - public IReadOnlyList InputPins => [ - new("Exec", PinType.Execution, 30), - new("Condition", PinType.Boolean, 50) - ]; - - public IReadOnlyList OutputPins => [ - new("LoopBody", PinType.Execution, 30), - new("LoopEnd", PinType.Execution, 50) - ]; - - public string? GetExecutionMethodBody() => null; - - public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) + /// + /// Loop 内置函数 — 循环控制流。 + /// BlockScript 语法:Loop(condition, "loopBodyBlock", "loopEndBlock") + /// + public class LoopFunction : IBuiltinFunctionDefinition { - var args = invoke.ArgumentList.Arguments; - var stmt = new FlowControlStatement + public string FunctionName => "Loop"; + public string DisplayName => "Loop"; + public bool IsFlowControl => true; + public bool IsNonExtractable => false; + public BlueprintNodeType? LegacyNodeType => BlueprintNodeType.Loop; + public bool IsBlockTerminator => true; + public FormattedStatementKind StatementKind => FormattedStatementKind.Loop; + public double NodeWidth => 120; + public double NodeHeight => 80; + + public IReadOnlyList InputPins => [ + new("Exec", PinType.Execution, 30), + new("Condition", PinType.Boolean, 50) + ]; + + public IReadOnlyList OutputPins => [ + new("LoopBody", PinType.Execution, 30), + new("LoopEnd", PinType.Execution, 50) + ]; + + public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) { - LineNumber = lineNumber, - SourceCode = exprText ?? invoke.ToFullString(), - ControlType = FlowControlType.Loop - }; - if (args.Count >= 1) stmt.ConditionExpression = args[0].Expression.ToString(); - if (args.Count >= 2) stmt.TrueBlockName = GetStringLiteral(args[1].Expression); - if (args.Count >= 3) stmt.FalseBlockName = GetStringLiteral(args[2].Expression); - return stmt; - } + var args = invoke.ArgumentList.Arguments; + var stmt = new FlowControlStatement + { + LineNumber = lineNumber, + SourceCode = exprText ?? invoke.ToFullString(), + ControlType = FlowControlType.Loop + }; + if (args.Count >= 1) stmt.ConditionExpression = args[0].Expression.ToString(); + if (args.Count >= 2) stmt.TrueBlockName = GetStringLiteral(args[1].Expression); + if (args.Count >= 3) stmt.FalseBlockName = GetStringLiteral(args[2].Expression); + return stmt; + } - public List FormatInvocation( - InvocationExpressionSyntax invoke, string blockName, - PipelineContext context, string? assignedVar) - { - // Loop is handled through FormatFlowControl, not FormatInvocation. - return []; - } + public List FormatInvocation( + InvocationExpressionSyntax invoke, string blockName, + PipelineContext context, string? assignedVar) + { + return []; + } - public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) => node; + public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) => node; - public void OnNodeCreated(BlueprintNode node, FormattedStatement stmt, PipelineContext context) - { - // Record generic deferred edges for LoopBody/LoopEnd arms - var arms = new List<(string PinName, string TargetBlockName)>(); - if (!string.IsNullOrEmpty(stmt.TrueBlockName)) - arms.Add(("LoopBody", stmt.TrueBlockName)); - if (!string.IsNullOrEmpty(stmt.FalseBlockName)) - arms.Add(("LoopEnd", stmt.FalseBlockName)); + public void OnNodeCreated(BlueprintNode node, FormattedStatement stmt, PipelineContext context) + { + var arms = new List<(string PinName, string TargetBlockName)>(); + if (!string.IsNullOrEmpty(stmt.TrueBlockName)) + arms.Add(("LoopBody", stmt.TrueBlockName)); + if (!string.IsNullOrEmpty(stmt.FalseBlockName)) + arms.Add(("LoopEnd", stmt.FalseBlockName)); + + if (arms.Count > 0) + { + context.DeferredEdges.Add(new DeferredControlFlowEdge + { + SourceStatementId = stmt.StatementId, + Arms = arms, + }); + } + + context.LoopDefs.Add((stmt.StatementId, stmt.TrueBlockName, stmt.FalseBlockName, stmt.BlockName)); + context.LoopNodesByParent[stmt.BlockName] = node; + } - if (arms.Count > 0) + public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) { - context.DeferredEdges.Add(new DeferredControlFlowEdge + var condition = helper.GetInputValue(node, "Condition"); + return new FlowControlStatement { - SourceStatementId = stmt.StatementId, - Arms = arms, - }); + ControlType = FlowControlType.Loop, + ConditionExpression = condition, + SourceCode = $"Loop({condition}, \"\", \"\");", + LineNumber = 1 + }; } - // Also record into LoopDefs for condition duplication support - context.LoopDefs.Add((stmt.StatementId, stmt.TrueBlockName, stmt.FalseBlockName, stmt.BlockName)); + public IEnumerable GetOutputArms() => + [ + new() { PinName = "LoopBody", IsLoopback = false }, + new() { PinName = "LoopEnd", IsLoopback = false } + ]; - // Store for ToLoopCond loopback resolution - context.LoopNodesByParent[stmt.BlockName] = node; + private static string GetStringLiteral(ExpressionSyntax expr) => + expr is LiteralExpressionSyntax lit ? lit.Token.ValueText : expr.ToString().Trim('"'); } +} - public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) +namespace KitX.Core.Workflow.BlockScripting +{ + public partial class BlockScriptExecutionGlobals { - var condition = helper.GetInputValue(node, "Condition"); - return new FlowControlStatement + /// + /// Loop while condition is true (three-argument syntax). + /// + public string? Loop(bool condition, string trueBlock, string falseBlock) { - ControlType = FlowControlType.Loop, - ConditionExpression = condition, - SourceCode = $"Loop({condition}, \"\", \"\");", - LineNumber = 1 - }; + NextBlock = condition ? trueBlock : falseBlock; + return NextBlock; + } } - - public IEnumerable GetOutputArms() => - [ - new() { PinName = "LoopBody", IsLoopback = false }, - new() { PinName = "LoopEnd", IsLoopback = false } - ]; - - private static string GetStringLiteral(ExpressionSyntax expr) => - expr is LiteralExpressionSyntax lit ? lit.Token.ValueText : expr.ToString().Trim('"'); } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PauseFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PauseFunction.cs index 7d2954a5..663f7aa5 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PauseFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PauseFunction.cs @@ -3,64 +3,78 @@ using KitX.Core.Contract.Workflow; using KitX.Core.Workflow.Blueprint; using KitX.Core.Workflow.Blueprint.Pipeline; +using System.Threading; -namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions; - -/// -/// Pause 内置函数 — 暂停执行指定毫秒数。 -/// -public class PauseFunction : IBuiltinFunctionDefinition +namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions { - public string FunctionName => "Pause"; - public string DisplayName => "Pause"; - public bool IsFlowControl => false; - public bool IsNonExtractable => true; - public BlueprintNodeType? LegacyNodeType => BlueprintNodeType.Pause; - public FormattedStatementKind StatementKind => FormattedStatementKind.Pause; - public double NodeWidth => 100; - public double NodeHeight => 50; + /// + /// Pause 内置函数 — 暂停执行指定毫秒数。 + /// + public class PauseFunction : IBuiltinFunctionDefinition + { + public string FunctionName => "Pause"; + public string DisplayName => "Pause"; + public bool IsFlowControl => false; + public bool IsNonExtractable => true; + public BlueprintNodeType? LegacyNodeType => BlueprintNodeType.Pause; + public FormattedStatementKind StatementKind => FormattedStatementKind.Pause; + public double NodeWidth => 100; + public double NodeHeight => 50; - public IReadOnlyList InputPins => [ - new("Exec", PinType.Execution, 20), - new("Milliseconds", PinType.Integer, 35) - ]; + public IReadOnlyList InputPins => [ + new("Exec", PinType.Execution, 20), + new("Milliseconds", PinType.Integer, 35) + ]; - public IReadOnlyList OutputPins => [ - new("Exec", PinType.Execution, 25) - ]; + public IReadOnlyList OutputPins => [ + new("Exec", PinType.Execution, 25) + ]; - public string? GetExecutionMethodBody() => null; + public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; - public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; + public List FormatInvocation( + InvocationExpressionSyntax invoke, string blockName, + PipelineContext context, string? assignedVar) + { + var args = invoke.ArgumentList.Arguments.Select(a => a.Expression.ToString()).ToList(); + return [new FormattedStatement + { + BlockName = blockName, + Kind = FormattedStatementKind.Pause, + FunctionName = FunctionName, + Arguments = args, + OriginalExpression = invoke.ToString(), + SourceLine = 0, + }]; + } - public List FormatInvocation( - InvocationExpressionSyntax invoke, string blockName, - PipelineContext context, string? assignedVar) - { - var args = invoke.ArgumentList.Arguments.Select(a => a.Expression.ToString()).ToList(); - return [new FormattedStatement + public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) => node; + + public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) { - BlockName = blockName, - Kind = FormattedStatementKind.Pause, - FunctionName = FunctionName, - Arguments = args, - OriginalExpression = invoke.ToString(), - SourceLine = 0, - }]; - } + var ms = helper.GetInputValue(node, "Milliseconds"); + return new ExpressionStatement + { + Expression = $"{FunctionName}({ms})", + SourceCode = $"{FunctionName}({ms});", + LineNumber = 1 + }; + } - public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) => node; + public IEnumerable GetOutputArms() => []; + } +} - public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) +namespace KitX.Core.Workflow.BlockScripting +{ + public partial class BlockScriptExecutionGlobals { - var ms = helper.GetInputValue(node, "Milliseconds"); - return new ExpressionStatement + /// + /// Pause execution. + /// + public void Pause(int milliseconds) { - Expression = $"{FunctionName}({ms})", - SourceCode = $"{FunctionName}({ms});", - LineNumber = 1 - }; + Thread.Sleep(milliseconds); + } } - - public IEnumerable GetOutputArms() => []; } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PluginCallFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PluginCallFunction.cs new file mode 100644 index 00000000..f5da66f7 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PluginCallFunction.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using KitX.Core.Contract.Workflow; +using KitX.Core.Workflow.Blueprint; +using KitX.Core.Workflow.Blueprint.Pipeline; +using KitX.Shared.CSharp.Device; +using Serilog; + +namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions +{ + /// + /// PluginCall 内置函数 — 调用本机插件函数。 + /// + public class PluginCallFunction : IBuiltinFunctionDefinition + { + public string FunctionName => "PluginCall"; + public string DisplayName => "PluginCall"; + public bool IsFlowControl => false; + public bool IsNonExtractable => true; + public BlueprintNodeType? LegacyNodeType => null; + public FormattedStatementKind StatementKind => FormattedStatementKind.Expression; + public double NodeWidth => 140; + public double NodeHeight => 80; + + public IReadOnlyList InputPins => [ + new("Exec", PinType.Execution, 20), + new("PluginName", PinType.String, 35), + new("MethodName", PinType.String, 35), + ]; + + public IReadOnlyList OutputPins => [ + new("Exec", PinType.Execution, 20), + new("Return", PinType.Any, 40) + ]; + + public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; + + public List FormatInvocation( + InvocationExpressionSyntax invoke, string blockName, + PipelineContext context, string? assignedVar) + { + var args = invoke.ArgumentList.Arguments + .Select(a => a.Expression.ToString()) + .ToList(); + return [new FormattedStatement + { + BlockName = blockName, + Kind = FormattedStatementKind.Expression, + FunctionName = FunctionName, + PubVarTarget = null, + Arguments = args, + OriginalExpression = invoke.ToString(), + SourceLine = 0, + }]; + } + + public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) => node; + + public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) => null; + + public IEnumerable GetOutputArms() => []; + } +} + +namespace KitX.Core.Workflow.BlockScripting +{ + public partial class BlockScriptExecutionGlobals + { + /// + /// 调用插件函数。所有分发策略由 RealPluginManager.CallAuto() 内部自动完成。 + /// + public object? PluginCall(string pluginName, string methodName, params object[] args) + { + if (_pluginManager == null) + { + Log.Warning("[BlockScriptGlobals] PluginCall: no plugin manager available, " + + "cannot call {PluginName}.{MethodName}", pluginName, methodName); + return null; + } + + var callInfo = new PluginCallInfo + { + PluginName = pluginName, + MethodName = methodName, + Parameters = args ?? Array.Empty() + }; + + try + { + if (_pluginManager is RealPluginManager realManager) + return realManager.CallAuto(callInfo); + + return _pluginManager.Call(callInfo); + } + catch (Exception ex) + { + Log.Error(ex, "[BlockScriptGlobals] PluginCall failed: {PluginName}.{MethodName}", + pluginName, methodName); + return null; + } + } + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PluginCallWithTargetFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PluginCallWithTargetFunction.cs new file mode 100644 index 00000000..990c89f3 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PluginCallWithTargetFunction.cs @@ -0,0 +1,162 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using KitX.Core.Contract.Workflow; +using KitX.Core.Device; +using KitX.Core.Workflow.Blueprint; +using KitX.Core.Workflow.Blueprint.Pipeline; +using KitX.Shared.CSharp.Device; +using Serilog; + +namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions +{ + /// + /// PluginCallWithTarget 内置函数 — 调用远程设备上的插件。 + /// 语法: PluginCallWithTarget("pluginName", "methodName", "targetDevice"[, arg1, arg2, ...]); + /// targetDevice 可以是设备名(字符串)或 TryGetDevice("pattern") 的返回值(DeviceInfo)。 + /// + public class PluginCallWithTargetFunction : IBuiltinFunctionDefinition + { + public string FunctionName => "PluginCallWithTarget"; + public string DisplayName => "PluginCallWithTarget"; + public bool IsFlowControl => false; + public bool IsNonExtractable => true; + public BlueprintNodeType? LegacyNodeType => BlueprintNodeType.Call; + public FormattedStatementKind StatementKind => FormattedStatementKind.PluginCallWithTarget; + public double NodeWidth => 140; + public double NodeHeight => 80; + + public IReadOnlyList InputPins => [ + new("Exec", PinType.Execution, 20), + new("PluginName", PinType.String, 35), + new("MethodName", PinType.String, 35), + new("TargetDevice", PinType.Any, 40), + ]; + + public IReadOnlyList OutputPins => [ + new("Exec", PinType.Execution, 20), + new("Return", PinType.Any, 40) + ]; + + public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; + + public List FormatInvocation( + InvocationExpressionSyntax invoke, string blockName, + PipelineContext context, string? assignedVar) + { + var args = invoke.ArgumentList.Arguments + .Select(a => a.Expression.ToString()) + .ToList(); + + return [new FormattedStatement + { + BlockName = blockName, + Kind = FormattedStatementKind.PluginCallWithTarget, + FunctionName = FunctionName, + PubVarTarget = null, + Arguments = args, + OriginalExpression = invoke.ToString(), + SourceLine = 0, + }]; + } + + public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) + { + if (node is CallNode call) + { + var args = stmt.Arguments; + if (args?.Count > 0) call.PluginName = StripQuotes(args[0]); + if (args?.Count > 1) call.FunctionName = StripQuotes(args[1]); + if (args?.Count > 2) call.TargetDevice = StripQuotes(args[2]); + } + return node; + } + + public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) + { + if (node is not CallNode call) return null; + + var pluginNameLit = $"\"{call.PluginName}\""; + var methodNameLit = $"\"{call.FunctionName}\""; + var targetDeviceExpr = !string.IsNullOrEmpty(call.TargetDevice) + ? $"\"{call.TargetDevice}\"" + : "null"; + + var extraArgPins = call.InputPins + .Where(p => p.Direction == PinDirection.Input && p.Name != "Exec") + .Skip(3) + .ToList(); + + var extraArgs = new List(); + foreach (var pin in extraArgPins) + { + extraArgs.Add(helper.GetInputValue(call, pin.Name)); + } + + string expression; + if (extraArgs.Count > 0) + expression = $"PluginCallWithTarget({pluginNameLit}, {methodNameLit}, {targetDeviceExpr}, {string.Join(", ", extraArgs)})"; + else + expression = $"PluginCallWithTarget({pluginNameLit}, {methodNameLit}, {targetDeviceExpr})"; + + return new ExpressionStatement + { + Expression = expression, + SourceCode = expression + ";", + LineNumber = 1 + }; + } + + public IEnumerable GetOutputArms() => []; + + private static string StripQuotes(string s) + { + s = s?.Trim() ?? ""; + if (s.Length >= 2 && s.StartsWith('"') && s.EndsWith('"')) + return s[1..^1]; + return s; + } + } +} + +namespace KitX.Core.Workflow.BlockScripting +{ + public partial class BlockScriptExecutionGlobals + { + /// + /// 调用远程设备上的插件函数。TargetDevice 参数指定目标设备。 + /// + public object? PluginCallWithTarget(string pluginName, string methodName, string targetDevice, params object[] args) + { + if (_pluginManager == null) + { + Log.Warning("[BlockScriptGlobals] PluginCallWithTarget: no plugin manager available, " + + "cannot call {PluginName}.{MethodName}@{Target}", pluginName, methodName, targetDevice); + return null; + } + + var callInfo = new PluginCallInfo + { + PluginName = pluginName, + MethodName = methodName, + Parameters = args ?? Array.Empty(), + TargetDevice = targetDevice + }; + + try + { + if (_pluginManager is RealPluginManager realManager) + return realManager.CallAuto(callInfo); + + return _pluginManager.Call(callInfo); + } + catch (Exception ex) + { + Log.Error(ex, "[BlockScriptGlobals] PluginCallWithTarget failed: {PluginName}.{MethodName}@{Target}", + pluginName, methodName, targetDevice); + return null; + } + } + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PrintFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PrintFunction.cs index b6752b3c..38c6f031 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PrintFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PrintFunction.cs @@ -4,63 +4,78 @@ using KitX.Core.Workflow.Blueprint; using KitX.Core.Workflow.Blueprint.Pipeline; -namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions; - -/// -/// Print 内置函数 — 输出值到控制台。 -/// -public class PrintFunction : IBuiltinFunctionDefinition +namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions { - public string FunctionName => "Print"; - public string DisplayName => "Print"; - public bool IsFlowControl => false; - public bool IsNonExtractable => true; - public BlueprintNodeType? LegacyNodeType => BlueprintNodeType.Print; - public FormattedStatementKind StatementKind => FormattedStatementKind.Print; - public double NodeWidth => 100; - public double NodeHeight => 50; + /// + /// Print 内置函数 — 输出值到控制台。 + /// + public class PrintFunction : IBuiltinFunctionDefinition + { + public string FunctionName => "Print"; + public string DisplayName => "Print"; + public bool IsFlowControl => false; + public bool IsNonExtractable => true; + public BlueprintNodeType? LegacyNodeType => BlueprintNodeType.Print; + public FormattedStatementKind StatementKind => FormattedStatementKind.Print; + public double NodeWidth => 100; + public double NodeHeight => 50; - public IReadOnlyList InputPins => [ - new("Exec", PinType.Execution, 20), - new("Value", PinType.Any, 35) - ]; + public IReadOnlyList InputPins => [ + new("Exec", PinType.Execution, 20), + new("Value", PinType.Any, 35) + ]; - public IReadOnlyList OutputPins => [ - new("Exec", PinType.Execution, 25) - ]; + public IReadOnlyList OutputPins => [ + new("Exec", PinType.Execution, 25) + ]; - public string? GetExecutionMethodBody() => null; // 已在 BlockScriptExecutionGlobals 中手工实现 + public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; - public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; + public List FormatInvocation( + InvocationExpressionSyntax invoke, string blockName, + PipelineContext context, string? assignedVar) + { + var args = invoke.ArgumentList.Arguments.Select(a => a.Expression.ToString()).ToList(); + return [new FormattedStatement + { + BlockName = blockName, + Kind = FormattedStatementKind.Print, + FunctionName = FunctionName, + Arguments = args, + OriginalExpression = invoke.ToString(), + SourceLine = 0, + }]; + } - public List FormatInvocation( - InvocationExpressionSyntax invoke, string blockName, - PipelineContext context, string? assignedVar) - { - var args = invoke.ArgumentList.Arguments.Select(a => a.Expression.ToString()).ToList(); - return [new FormattedStatement + public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) => node; + + public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) { - BlockName = blockName, - Kind = FormattedStatementKind.Print, - FunctionName = FunctionName, - Arguments = args, - OriginalExpression = invoke.ToString(), - SourceLine = 0, - }]; - } + var value = helper.GetInputValue(node, "Value"); + return new ExpressionStatement + { + Expression = $"{FunctionName}({value})", + SourceCode = $"{FunctionName}({value});", + LineNumber = 1 + }; + } - public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) => node; + public IEnumerable GetOutputArms() => []; + } +} - public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) +namespace KitX.Core.Workflow.BlockScripting +{ + public partial class BlockScriptExecutionGlobals { - var value = helper.GetInputValue(node, "Value"); - return new ExpressionStatement + /// + /// Print a value. + /// + public void Print(object? value) { - Expression = $"{FunctionName}({value})", - SourceCode = $"{FunctionName}({value});", - LineNumber = 1 - }; + var str = value?.ToString() ?? "null"; + _output.Add(str); + WorkflowOutput.WriteLine(value); + } } - - public IEnumerable GetOutputArms() => []; } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/SetFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/SetFunction.cs index b07246d3..f38d891b 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/SetFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/SetFunction.cs @@ -1,106 +1,105 @@ using System.Collections.Generic; +using System.Linq; using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; using KitX.Core.Workflow.Blueprint; using KitX.Core.Workflow.Blueprint.Pipeline; -namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions; - -/// -/// Set 内置函数 — 设置变量值。 -/// -public class SetFunction : IBuiltinFunctionDefinition +namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions { - public string FunctionName => "Set"; - public string DisplayName => "Set"; - public bool IsFlowControl => false; - public bool IsNonExtractable => true; - public BlueprintNodeType? LegacyNodeType => BlueprintNodeType.Set; - public FormattedStatementKind StatementKind => FormattedStatementKind.Set; - public double NodeWidth => 120; - public double NodeHeight => 60; + /// + /// Set 内置函数 — 设置变量值。 + /// + public class SetFunction : IBuiltinFunctionDefinition + { + public string FunctionName => "Set"; + public string DisplayName => "Set"; + public bool IsFlowControl => false; + public bool IsNonExtractable => true; + public BlueprintNodeType? LegacyNodeType => BlueprintNodeType.Set; + public FormattedStatementKind StatementKind => FormattedStatementKind.Set; + public double NodeWidth => 120; + public double NodeHeight => 60; - public IReadOnlyList InputPins => [ - new("Exec", PinType.Execution, 20), - new("Value", PinType.Any, 40) - ]; + public IReadOnlyList InputPins => [ + new("Exec", PinType.Execution, 20), + new("Value", PinType.Any, 40) + ]; - public IReadOnlyList OutputPins => [ - new("Exec", PinType.Execution, 20) - ]; + public IReadOnlyList OutputPins => [ + new("Exec", PinType.Execution, 20) + ]; - public string? GetExecutionMethodBody() => null; + public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; - public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; + public List FormatInvocation( + InvocationExpressionSyntax invoke, string blockName, + PipelineContext context, string? assignedVar) + { + var currentArgExprs = invoke.ArgumentList.Arguments.Select(a => a.Expression.ToString()).ToList(); - public List FormatInvocation( - InvocationExpressionSyntax invoke, string blockName, - PipelineContext context, string? assignedVar) - { - var currentArgExprs = invoke.ArgumentList.Arguments.Select(a => a.Expression.ToString()).ToList(); + string? setVarName = null; + if (invoke.ArgumentList.Arguments.Count > 0) + { + var firstArgExpr = invoke.ArgumentList.Arguments[0].Expression; + var varNameLiteral = ExprUtils.GetStringLiteralValue(firstArgExpr); + setVarName = varNameLiteral ?? currentArgExprs[0]; + currentArgExprs.RemoveAt(0); + } - // First arg is varName (string literal) — extract it - string? setVarName = null; - if (invoke.ArgumentList.Arguments.Count > 0) - { - var firstArgExpr = invoke.ArgumentList.Arguments[0].Expression; - var varNameLiteral = ExprUtils.GetStringLiteralValue(firstArgExpr); - setVarName = varNameLiteral ?? currentArgExprs[0]; - currentArgExprs.RemoveAt(0); + return [new FormattedStatement + { + BlockName = blockName, + Kind = FormattedStatementKind.Set, + FunctionName = FunctionName, + SetVarName = setVarName, + Arguments = currentArgExprs, + OriginalExpression = invoke.ToString(), + SourceLine = 0, + }]; } - return [new FormattedStatement + public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) { - BlockName = blockName, - Kind = FormattedStatementKind.Set, - FunctionName = FunctionName, - SetVarName = setVarName, - Arguments = currentArgExprs, - OriginalExpression = invoke.ToString(), - SourceLine = 0, - }]; - } - - public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) - { - var varName = stmt.SetVarName ?? ""; - if (node is SetNode sn) - sn.VarName = varName; - else if (node is BuiltinFunctionNode bfn) - bfn.Properties["VarName"] = varName; - return node; - } + var varName = stmt.SetVarName ?? ""; + if (node is SetNode sn) + sn.VarName = varName; + else if (node is BuiltinFunctionNode bfn) + bfn.Properties["VarName"] = varName; + return node; + } - public (string?, string?, string?) ExtractStatementFields( - InvocationExpressionSyntax invoke, List expandedArgs, - string? assignedVar, PipelineContext context) - { - string? setVarName = null; - if (expandedArgs.Count > 0) + public (string?, string?, string?) ExtractStatementFields( + InvocationExpressionSyntax invoke, List expandedArgs, + string? assignedVar, PipelineContext context) { - var firstArgExpr = invoke.ArgumentList.Arguments[0].Expression; - setVarName = ExprUtils.GetStringLiteralValue(firstArgExpr) ?? expandedArgs[0]; - expandedArgs.RemoveAt(0); + string? setVarName = null; + if (expandedArgs.Count > 0) + { + var firstArgExpr = invoke.ArgumentList.Arguments[0].Expression; + setVarName = ExprUtils.GetStringLiteralValue(firstArgExpr) ?? expandedArgs[0]; + expandedArgs.RemoveAt(0); + } + return (setVarName, null, null); } - return (setVarName, null, null); - } - public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) - { - var varName = node switch + public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) { - SetNode sn => sn.VarName, - BuiltinFunctionNode bfn => bfn.Properties.GetValueOrDefault("VarName", ""), - _ => "" - }; - var value = helper.GetInputValue(node, "Value"); - return new ExpressionStatement - { - Expression = $"{FunctionName}(\"{varName}\", {value})", - SourceCode = $"{FunctionName}(\"{varName}\", {value});", - LineNumber = 1 - }; - } + var varName = node switch + { + SetNode sn => sn.VarName, + BuiltinFunctionNode bfn => bfn.Properties.GetValueOrDefault("VarName", ""), + _ => "" + }; + var value = helper.GetInputValue(node, "Value"); + return new ExpressionStatement + { + Expression = $"{FunctionName}(\"{varName}\", {value})", + SourceCode = $"{FunctionName}(\"{varName}\", {value});", + LineNumber = 1 + }; + } - public IEnumerable GetOutputArms() => []; + public IEnumerable GetOutputArms() => []; + } } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/ToLoopCondFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/ToLoopCondFunction.cs index f79f2d3d..75545078 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/ToLoopCondFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/ToLoopCondFunction.cs @@ -4,117 +4,134 @@ using KitX.Core.Workflow.Blueprint; using KitX.Core.Workflow.Blueprint.Pipeline; -namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions; - -/// -/// ToLoopCond 内置函数 — 标记循环体的结尾并返回循环条件块。 -/// BlockScript 语法:ToLoopCond("parentBlockName") -/// -public class ToLoopCondFunction : IBuiltinFunctionDefinition +namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions { - public string FunctionName => "ToLoopCond"; - public string DisplayName => "ToLoopCond"; - public bool IsFlowControl => true; - public bool IsNonExtractable => false; - public bool IsBlockTerminator => true; - public FormattedStatementKind StatementKind => FormattedStatementKind.ToLoopCond; - public double NodeWidth => 80; - public double NodeHeight => 60; + /// + /// ToLoopCond 内置函数 — 标记循环体的结尾并返回循环条件块。 + /// BlockScript 语法:ToLoopCond("parentBlockName") + /// + public class ToLoopCondFunction : IBuiltinFunctionDefinition + { + public string FunctionName => "ToLoopCond"; + public string DisplayName => "ToLoopCond"; + public bool IsFlowControl => true; + public bool IsNonExtractable => false; + public bool IsBlockTerminator => true; + public FormattedStatementKind StatementKind => FormattedStatementKind.ToLoopCond; + public double NodeWidth => 80; + public double NodeHeight => 60; - public IReadOnlyList InputPins => [ - new("Exec", PinType.Execution, 30) - ]; + public IReadOnlyList InputPins => [ + new("Exec", PinType.Execution, 30) + ]; - public IReadOnlyList OutputPins => [ - new("Exec", PinType.Execution, 30) - ]; + public IReadOnlyList OutputPins => [ + new("Exec", PinType.Execution, 30) + ]; - public string? GetExecutionMethodBody() => """ - public string? ToLoopCond(string parentBlockName) + /// + /// 已在 partial class BlockScriptExecutionGlobals 中实现。 + /// 此处返回 null 表示执行完全通过 Globals 方法调用处理。 + /// + + + public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) { - NextBlock = parentBlockName; - return NextBlock; + var args = invoke.ArgumentList.Arguments; + var stmt = new FlowControlStatement + { + LineNumber = lineNumber, + SourceCode = exprText ?? invoke.ToFullString(), + ControlType = FlowControlType.ToLoopCond + }; + if (args.Count >= 1) stmt.ToLoopCondReturnTo = GetStringLiteral(args[0].Expression); + return stmt; } - """; - public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) - { - var args = invoke.ArgumentList.Arguments; - var stmt = new FlowControlStatement + public List FormatInvocation( + InvocationExpressionSyntax invoke, string blockName, + PipelineContext context, string? assignedVar) { - LineNumber = lineNumber, - SourceCode = exprText ?? invoke.ToFullString(), - ControlType = FlowControlType.ToLoopCond - }; - if (args.Count >= 1) stmt.ToLoopCondReturnTo = GetStringLiteral(args[0].Expression); - return stmt; - } - - public List FormatInvocation( - InvocationExpressionSyntax invoke, string blockName, - PipelineContext context, string? assignedVar) - { - // ToLoopCond is handled through FormatFlowControl, not FormatInvocation. - return []; - } + // ToLoopCond is handled through FormatFlowControl, not FormatInvocation. + return []; + } - public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) => node; + public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) => node; - public void OnNodeCreated(BlueprintNode node, FormattedStatement stmt, PipelineContext context) - { - var returnToBlock = stmt.ToLoopCondReturnTo; - if (!string.IsNullOrEmpty(returnToBlock)) + public void OnNodeCreated(BlueprintNode node, FormattedStatement stmt, PipelineContext context) { - context.DeferredEdges.Add(new DeferredControlFlowEdge + var returnToBlock = stmt.ToLoopCondReturnTo; + if (!string.IsNullOrEmpty(returnToBlock)) { - SourceStatementId = stmt.StatementId, - Arms = [("Exec", returnToBlock)], - }); + context.DeferredEdges.Add(new DeferredControlFlowEdge + { + SourceStatementId = stmt.StatementId, + Arms = [("Exec", returnToBlock)], + }); + } } - } - public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) - { - // Trace exec output connection to find the target block name - var execOut = node.OutputPins.FirstOrDefault(p => p.Name == "Exec"); - if (execOut != null) + public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) { - var conn = helper.Blueprint.Connections - .FirstOrDefault(c => c.SourcePinId == execOut.Id); - if (conn != null) + // Trace exec output connection to find the target block name + var execOut = node.OutputPins.FirstOrDefault(p => p.Name == "Exec"); + if (execOut != null) { - var targetNode = helper.Blueprint.GetNodeById(conn.TargetNodeId); - if (targetNode != null) + var conn = helper.Blueprint.Connections + .FirstOrDefault(c => c.SourcePinId == execOut.Id); + if (conn != null) { - var targetScope = helper.Blueprint.BlockScopes - .FirstOrDefault(s => s.NodeIds.Contains(targetNode.Id)); - if (targetScope != null) + var targetNode = helper.Blueprint.GetNodeById(conn.TargetNodeId); + if (targetNode != null) { - var returnTo = targetScope.Name; - return new FlowControlStatement + var targetScope = helper.Blueprint.BlockScopes + .FirstOrDefault(s => s.NodeIds.Contains(targetNode.Id)); + if (targetScope != null) { - ControlType = FlowControlType.ToLoopCond, - ToLoopCondReturnTo = returnTo, - SourceCode = $"NextBlock = ToLoopCond(\"{returnTo}\");", - LineNumber = 1 - }; + var returnTo = targetScope.Name; + return new FlowControlStatement + { + ControlType = FlowControlType.ToLoopCond, + ToLoopCondReturnTo = returnTo, + SourceCode = $"NextBlock = ToLoopCond(\"{returnTo}\");", + LineNumber = 1 + }; + } } } } + + return new FlowControlStatement + { + ControlType = FlowControlType.ToLoopCond, + SourceCode = "ToLoopCond();", + LineNumber = 1 + }; } - return new FlowControlStatement - { - ControlType = FlowControlType.ToLoopCond, - SourceCode = "ToLoopCond();", - LineNumber = 1 - }; - } + public IEnumerable GetOutputArms() => [ + new() { PinName = "Exec", IsLoopback = false } + ]; - public IEnumerable GetOutputArms() => [ - new() { PinName = "Exec", IsLoopback = false } - ]; + private static string GetStringLiteral(ExpressionSyntax expr) => + expr is LiteralExpressionSyntax lit ? lit.Token.ValueText : expr.ToString().Trim('"'); + } +} - private static string GetStringLiteral(ExpressionSyntax expr) => - expr is LiteralExpressionSyntax lit ? lit.Token.ValueText : expr.ToString().Trim('"'); +namespace KitX.Core.Workflow.BlockScripting +{ + // ────────────────────────────────────────────── + // Partial class — ToLoopCond 的运行时方法 + // ────────────────────────────────────────────── + public partial class BlockScriptExecutionGlobals + { + /// + /// ToLoopCond - marks the end of a loop body and returns to the loop condition block. + /// + public string? ToLoopCond(string parentBlockName) + { + NextBlock = parentBlockName; + return NextBlock; + } + } } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/TryGetDeviceFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/TryGetDeviceFunction.cs new file mode 100644 index 00000000..1a9a05ed --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/TryGetDeviceFunction.cs @@ -0,0 +1,192 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using KitX.Core.Contract.Workflow; +using KitX.Core.Device; +using KitX.Core.Workflow.Blueprint; +using KitX.Core.Workflow.Blueprint.Pipeline; +using KitX.Shared.CSharp.Device; +using Serilog; + +namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions +{ + /// + /// TryGetDevice 内置函数 — 根据设备名称查找已连接设备。 + /// 返回 DeviceInfo 对象(可用于 PluginCallWithTarget 的 targetDevice 参数)。 + /// 如果找不到设备,返回 null。 + /// 语法: TryGetDevice("DeviceName") + /// + public class TryGetDeviceFunction : IBuiltinFunctionDefinition + { + public string FunctionName => "TryGetDevice"; + public string DisplayName => "TryGetDevice"; + public bool IsFlowControl => false; + public bool IsNonExtractable => false; + public BlueprintNodeType? LegacyNodeType => null; + public FormattedStatementKind StatementKind => FormattedStatementKind.TryGetDevice; + public double NodeWidth => 120; + public double NodeHeight => 60; + + public IReadOnlyList InputPins => [ + new("Exec", PinType.Execution, 20), + new("Pattern", PinType.String, 35), + ]; + + public IReadOnlyList OutputPins => [ + new("Exec", PinType.Execution, 20), + new("DeviceInfo", PinType.Any, 40) + ]; + + public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; + + public List FormatInvocation( + InvocationExpressionSyntax invoke, string blockName, + PipelineContext context, string? assignedVar) + { + var args = invoke.ArgumentList.Arguments + .Select(a => a.Expression.ToString()) + .ToList(); + + string? pubVarTarget; + if (!string.IsNullOrEmpty(assignedVar)) + { + pubVarTarget = assignedVar; + } + else + { + pubVarTarget = ExprUtils.GeneratePubVarName(context.NextPubVarCounter++); + if (!context.PubVarNames.Contains(pubVarTarget)) + context.PubVarNames.Add(pubVarTarget); + } + + return [new FormattedStatement + { + BlockName = blockName, + Kind = FormattedStatementKind.TryGetDevice, + FunctionName = FunctionName, + PubVarTarget = pubVarTarget, + Arguments = args, + OriginalExpression = invoke.ToString(), + SourceLine = 0, + }]; + } + + public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) + { + if (node is BuiltinFunctionNode bfn && stmt.Arguments?.Count > 0) + { + bfn.Properties["Pattern"] = StripQuotes(stmt.Arguments[0]); + } + return node; + } + + public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) + { + string pattern; + if (node is BuiltinFunctionNode bfn) + pattern = bfn.Properties.GetValueOrDefault("Pattern", "") ?? ""; + else + pattern = ""; + + var patternLit = $"\"{pattern}\""; + var value = helper.GetInputValue(node, "DeviceInfo"); + return new ExpressionStatement + { + Expression = $"TryGetDevice({patternLit})", + SourceCode = $"{value} = TryGetDevice({patternLit});", + LineNumber = 1 + }; + } + + public IEnumerable GetOutputArms() => []; + + private static string StripQuotes(string s) + { + s = s?.Trim() ?? ""; + if (s.Length >= 2 && s.StartsWith('"') && s.EndsWith('"')) + return s[1..^1]; + return s; + } + } +} + +namespace KitX.Core.Workflow.BlockScripting +{ + public partial class BlockScriptExecutionGlobals + { + /// + /// 根据设备名称查找已连接设备的 DeviceInfo。 + /// 内部使用 DevicesServer._signedDeviceTokens 查找匹配的 DeviceLocator。 + /// + public object? TryGetDevice(string deviceName) + { + if (string.IsNullOrEmpty(deviceName)) + { + Log.Warning("[BlockScriptGlobals] TryGetDevice: deviceName is empty"); + return null; + } + + try + { + var tokensField = typeof(DevicesServer) + .GetField("_signedDeviceTokens", + BindingFlags.NonPublic | BindingFlags.Instance); + + if (tokensField == null) + { + Log.Warning("[BlockScriptGlobals] TryGetDevice: _signedDeviceTokens field not found"); + return null; + } + + var tokens = tokensField.GetValue(DevicesServer.Instance) as System.Collections.IDictionary; + if (tokens == null) + { + Log.Warning("[BlockScriptGlobals] TryGetDevice: _signedDeviceTokens is null"); + return null; + } + + foreach (System.Collections.DictionaryEntry entry in tokens) + { + if (entry.Key is DeviceLocator locator && + locator.DeviceName.Equals(deviceName, StringComparison.OrdinalIgnoreCase)) + { + Log.Information("[BlockScriptGlobals] TryGetDevice: found device {DeviceName} (IPv4={IPv4})", + locator.DeviceName, locator.IPv4); + + var discoveryInstance = typeof(DevicesDiscoveryServer) + .GetProperty("Instance", + BindingFlags.NonPublic | BindingFlags.Static)? + .GetValue(null); + + if (discoveryInstance != null) + { + var defaultInfoProperty = discoveryInstance.GetType() + .GetProperty("DefaultDeviceInfo"); + if (defaultInfoProperty?.GetValue(discoveryInstance) is DeviceInfo info && + info.Device.IsSameDevice(locator)) + { + return info; + } + } + + return new DeviceInfo + { + Device = locator, + SendTime = DateTime.UtcNow + }; + } + } + + Log.Warning("[BlockScriptGlobals] TryGetDevice: device not found: {DeviceName}", deviceName); + return null; + } + catch (Exception ex) + { + Log.Error(ex, "[BlockScriptGlobals] TryGetDevice failed for {DeviceName}", deviceName); + return null; + } + } + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/IBuiltinFunctionDefinition.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/IBuiltinFunctionDefinition.cs index 6dc6e47e..79394eab 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/IBuiltinFunctionDefinition.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/IBuiltinFunctionDefinition.cs @@ -71,14 +71,6 @@ public interface IBuiltinFunctionDefinition /// 输出引脚描述 IReadOnlyList OutputPins { get; } - // ─── 执行 ─────────────────────────────────────── - - /// - /// 返回完整的方法签名+方法体字符串,用于编译到 CSharpScript 执行上下文。 - /// 返回 null 表示执行完全通过语句管线处理(如简单表达式函数)。 - /// - string? GetExecutionMethodBody(); - // ─── 解析(BlockScript → AST)───────────────── /// diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptAssemblyCompiler.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptAssemblyCompiler.cs index a32fa5c1..c5049730 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptAssemblyCompiler.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptAssemblyCompiler.cs @@ -41,7 +41,7 @@ internal class ScriptAssemblyCompiler private static readonly HashSet GlobalsIdentifiers = new(StringComparer.Ordinal) { "Print", "Set", "Get", "Branch", "Loop", "ToLoopCond", - "Flip", "PluginCall", "Pause", "NextBlock" + "Flip", "PluginCall", "PluginCallWithTarget", "Pause", "NextBlock" }; /// @@ -967,6 +967,13 @@ private SwitchSectionSyntax GenerateFormattedBlockCase( sourceType = helperReturnTypes.TryGetValue(stmt.FunctionName ?? "", out var rt) ? rt : "object"; } + else if (stmt.FunctionName == "PluginCallWithTarget") + { + // PluginCallWithTarget("plugin", "method", "device", args...) + // Must check BEFORE FullFunctionName.Contains('.') since + // "G.PluginCallWithTarget" also contains a dot. + rawExpr = BuildPluginCallWithTargetExpression(stmt, pubVarTypes); + } else if (stmt.FullFunctionName != null && stmt.FullFunctionName.Contains('.')) { rawExpr = BuildPluginCallExpression(stmt, pubVarTypes); @@ -1076,6 +1083,67 @@ private SwitchSectionSyntax GenerateFormattedBlockCase( break; } + case FormattedStatementKind.PluginCallWithTarget: + { + // G.PluginCallWithTarget("pluginName", "methodName", targetDeviceExpr[, arg1, arg2, ...]); + // Arguments[0]=pluginName, [1]=methodName, [2]=targetDevice, [3...]=call args + // targetDeviceExpr may be a string literal or a variable (e.g. TryGetDevice(...) result) + var args = new List(); + + // pluginName (arg 0) + if (stmt.Arguments.Count > 0) + args.Add(Argument(ResolveArgumentExpression(stmt.Arguments[0], pubVarTypes))); + else + args.Add(Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, Literal("")))); + + // methodName (arg 1) + if (stmt.Arguments.Count > 1) + args.Add(Argument(ResolveArgumentExpression(stmt.Arguments[1], pubVarTypes))); + else + args.Add(Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, Literal("")))); + + // targetDevice (arg 2) — may be a variable reference or TryGetDevice(...) call + if (stmt.Arguments.Count > 2) + args.Add(Argument(ResolveArgumentExpression(stmt.Arguments[2], pubVarTypes))); + else + args.Add(Argument(LiteralExpression(SyntaxKind.NullLiteralExpression))); + + // Extra call args (arg 3+) + for (int i = 3; i < stmt.Arguments.Count; i++) + args.Add(Argument(ResolveArgumentExpression(stmt.Arguments[i], pubVarTypes))); + + caseStatements.Add( + ExpressionStatement( + InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("G"), IdentifierName("PluginCallWithTarget")), + ArgumentList(SeparatedList(args))))); + + break; + } + + case FormattedStatementKind.TryGetDevice: + { + // var pubVar = G.TryGetDevice("pattern"); + if (stmt.Arguments.Count > 0 && !string.IsNullOrEmpty(stmt.PubVarTarget)) + { + var patternExpr = ResolveArgumentExpression(stmt.Arguments[0], pubVarTypes); + caseStatements.Add( + LocalDeclarationStatement( + VariableDeclaration(IdentifierName("var")) + .AddVariables( + VariableDeclarator(Identifier(stmt.PubVarTarget)) + .WithInitializer( + EqualsValueClause( + InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("G"), IdentifierName("TryGetDevice")), + ArgumentList(SeparatedList(new[] { Argument(patternExpr) })))))))); + } + + break; + } + case FormattedStatementKind.Set: { // G.Set("varName", value); @@ -1230,6 +1298,39 @@ private static InvocationExpressionSyntax BuildPluginCallExpression( ArgumentList(SeparatedList(pluginCallArgs))); } + /// + /// Builds G.PluginCallWithTarget("pluginName", "methodName", "targetDevice", args...) + /// expression for cross-device plugin calls. + /// + private static InvocationExpressionSyntax BuildPluginCallWithTargetExpression( + FormattedStatement stmt, Dictionary pubVarTypes) + { + // Arguments: [0]=pluginName, [1]=methodName, [2]=targetDevice, [3...]=call args + var pluginNameArg = stmt.Arguments.Count > 0 + ? stmt.Arguments[0] : "\"\""; + var methodNameArg = stmt.Arguments.Count > 1 + ? stmt.Arguments[1] : "\"\""; + var targetDeviceArg = stmt.Arguments.Count > 2 + ? stmt.Arguments[2] : "\"\""; + var callArgs = stmt.Arguments.Count > 3 + ? stmt.Arguments.Skip(3).ToList() + : new List(); + + var args = new List + { + Argument(ResolveArgumentExpression(pluginNameArg, pubVarTypes)), + Argument(ResolveArgumentExpression(methodNameArg, pubVarTypes)), + Argument(ResolveArgumentExpression(targetDeviceArg, pubVarTypes)) + }; + args.AddRange(callArgs.Select(a => + Argument(ResolveArgumentExpression(a, pubVarTypes)))); + + return InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("G"), IdentifierName("PluginCallWithTarget")), + ArgumentList(SeparatedList(args))); + } + /// /// Resolves a formatted argument string into a Roslyn . /// PubVar references are returned as . diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGBuilderFromBlueprint.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGBuilderFromBlueprint.cs index b64784af..ed263266 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGBuilderFromBlueprint.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGBuilderFromBlueprint.cs @@ -804,9 +804,24 @@ private static void SetParentLoopReferences(ControlFlowGraph cfg) { if (node is not CallNode call) return null; var callArgs = _exportHelper.GetInputArgs(call); - var funcRef = string.IsNullOrEmpty(call.PluginName) - ? call.FunctionName : $"{call.PluginName}.{call.FunctionName}"; - var sourceCode = $"{funcRef}({callArgs})"; + string sourceCode; + if (!string.IsNullOrEmpty(call.TargetDevice)) + { + // 跨设备调用:PluginCallWithTarget("plugin", "method", "device", args...) + var pluginNameLit = $"\"{call.PluginName}\""; + var methodNameLit = $"\"{call.FunctionName}\""; + var targetDeviceLit = $"\"{call.TargetDevice}\""; + sourceCode = string.IsNullOrEmpty(callArgs) + ? $"PluginCallWithTarget({pluginNameLit}, {methodNameLit}, {targetDeviceLit})" + : $"PluginCallWithTarget({pluginNameLit}, {methodNameLit}, {targetDeviceLit}, {callArgs})"; + } + else + { + // 本地调用:PluginName.MethodName(args...) + var funcRef = string.IsNullOrEmpty(call.PluginName) + ? call.FunctionName : $"{call.PluginName}.{call.FunctionName}"; + sourceCode = $"{funcRef}({callArgs})"; + } var callStmt = new ExpressionStatement { Expression = sourceCode, diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/FormattedBlockScript.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/FormattedBlockScript.cs index 846407b9..7ef3a927 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/FormattedBlockScript.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/FormattedBlockScript.cs @@ -146,4 +146,13 @@ public enum FormattedStatementKind /// Plain expression without assignment Expression, + + /// PluginCallWithTarget(pluginName, methodName, targetDevice, args...) — cross-device plugin call + PluginCallWithTarget, + + /// TryGetDevice(deviceSearchPattern) — returns DeviceInfo or null + TryGetDevice, + + /// PluginCall(pluginName, methodName[, args...]) — local plugin call + PluginCall, } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/NodeBuilder.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/NodeBuilder.cs index e73d0001..c48bffe9 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/NodeBuilder.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/NodeBuilder.cs @@ -246,7 +246,17 @@ private bool IsRegistryFlowControlTerminator(FormattedStatement stmt) var callNode = (CallNode)_registry.Create(BlueprintNodeType.Call); // Parse plugin name from full dotted method name (e.g. "TestPlugin.WPF.Core.HelloKitX") - if (!string.IsNullOrEmpty(stmt.FullFunctionName) && stmt.FullFunctionName.Contains('.')) + // PluginCallWithTarget has G.PluginCallWithTarget as FullFunctionName, handle it first + if (stmt.FunctionName == "PluginCallWithTarget") + { + // G.PluginCallWithTarget("plugin", "method", "device", ...) + // Arguments[0]=pluginName, [1]=methodName, [2]=targetDevice + var args = stmt.Arguments; + callNode.PluginName = args?.Count > 0 ? StripQuotes(args[0]) : ""; + callNode.FunctionName = args?.Count > 1 ? StripQuotes(args[1]) : ""; + callNode.TargetDevice = args?.Count > 2 ? StripQuotes(args[2]) : null; + } + else if (!string.IsNullOrEmpty(stmt.FullFunctionName) && stmt.FullFunctionName.Contains('.')) { var lastDot = stmt.FullFunctionName.LastIndexOf('.'); callNode.PluginName = stmt.FullFunctionName.Substring(0, lastDot); @@ -431,4 +441,14 @@ private void AddParamPins(BlueprintNode node, string funcName, int argCount) } } } + + /// Strips surrounding double-quote characters from a string literal. + private static string StripQuotes(string s) + { + if (s == null) return ""; + s = s.Trim(); + if (s.Length >= 2 && s.StartsWith('"') && s.EndsWith('"')) + return s[1..^1]; + return s; + } } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/RealPluginManager.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/RealPluginManager.cs index c2dfa486..57e54ab7 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/RealPluginManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/RealPluginManager.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Concurrent; +using System.Linq; +using System.Net.Http; using System.Text; using System.Text.Json; using System.Threading; @@ -7,6 +9,7 @@ using KitX.Core.Contract.Workflow; using KitX.Core.Contract.Plugin; using KitX.Core.Device; +using KitX.Shared.CSharp.Device; using KitX.Shared.CSharp.Plugin; using KitX.Shared.CSharp.WebCommand; using KitX.Shared.CSharp.WebCommand.Infos; @@ -25,6 +28,7 @@ namespace KitX.Core.Workflow; public class RealPluginManager : IPluginManager, KcsIPluginManager { private readonly PluginsServer _pluginsServer; + private readonly IDeviceHttpClient _deviceHttpClient; private readonly JsonSerializerOptions _serializerOptions = new() { WriteIndented = true, @@ -40,7 +44,7 @@ public class RealPluginManager : IPluginManager, KcsIPluginManager /// /// 无参构造函数(供 KScript.Parser 动态创建实例使用) /// - public RealPluginManager() : this(PluginsServer.Instance) + public RealPluginManager() : this(PluginsServer.Instance, new DeviceHttpClient()) { } @@ -49,8 +53,19 @@ public RealPluginManager() : this(PluginsServer.Instance) /// /// 插件服务器实例 public RealPluginManager(PluginsServer pluginsServer) + : this(pluginsServer, new DeviceHttpClient()) + { + } + + /// + /// 构造函数 + /// + /// 插件服务器实例 + /// HTTP 客户端,用于跨设备调用 + public RealPluginManager(PluginsServer pluginsServer, IDeviceHttpClient deviceHttpClient) { _pluginsServer = pluginsServer; + _deviceHttpClient = deviceHttpClient ?? new DeviceHttpClient(); // 订阅插件消息接收事件以处理响应 _pluginsServer.PluginMessageReceived += OnPluginMessageReceived; @@ -145,6 +160,16 @@ private void OnPluginMessageReceived(object? sender, PluginMessageReceivedEventA public void Call(PluginCallInfo callInfo) { Log.Information($"[RealPluginManager] Call() invoked (fire-and-forget): {callInfo.PluginName}.{callInfo.MethodName}"); + + // 如果指定了目标设备,通过 RemoteCallAsync 路由(fire-and-forget) + if (!string.IsNullOrEmpty(callInfo.TargetDevice)) + { + Log.Information("[RealPluginManager] Call: TargetDevice={Device} set, routing via RemoteCallAsync (fire-and-forget)", + callInfo.TargetDevice); + _ = Task.Run(() => RemoteCallAsync(callInfo, callInfo.TargetDevice)); + return; + } + SendRequestWithoutWaitAsync(callInfo); } @@ -154,6 +179,16 @@ public void Call(PluginCallInfo callInfo) public T Call(PluginCallInfo callInfo) { Log.Information($"[RealPluginManager] Call<{typeof(T).Name}>() invoked: {callInfo.PluginName}.{callInfo.MethodName}"); + + // 如果指定了目标设备,通过 RemoteCallAsync 路由 + if (!string.IsNullOrEmpty(callInfo.TargetDevice)) + { + Log.Information("[RealPluginManager] Call<{Type}>: TargetDevice={Device} set, routing via RemoteCallAsync", + typeof(T).Name, callInfo.TargetDevice); + var remoteResult = RemoteCallAsync(callInfo, callInfo.TargetDevice).GetAwaiter().GetResult(); + return (T)remoteResult!; + } + var result = CallAsync(callInfo).GetAwaiter().GetResult(); return ParseResult(result); } @@ -510,4 +545,329 @@ public bool IsMethodExists(string pluginName, string methodName) return _pluginsServer.Connections .FirstOrDefault(c => c.PluginInfo?.Name == pluginName); } + + /// + /// 根据设备名称查找已连接设备的 token。 + /// + private string? FindDeviceToken(DeviceInfo deviceInfo) + { + if (deviceInfo?.Device == null) + return null; + + try + { + // Access DevicesServer's signed tokens dictionary directly + // DevicesServer._signedDeviceTokens is Dictionary + // We need to find the token where the DeviceLocator matches deviceInfo.Device + var tokensField = typeof(DevicesServer) + .GetField("_signedDeviceTokens", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + + if (tokensField == null) + { + Log.Warning("[RealPluginManager] Could not access _signedDeviceTokens field"); + return null; + } + + var tokens = tokensField.GetValue(DevicesServer.Instance) as System.Collections.IDictionary; + if (tokens == null) + return null; + + foreach (System.Collections.DictionaryEntry entry in tokens) + { + if (entry.Key is KitX.Shared.CSharp.Device.DeviceLocator locator && + locator.IsSameDevice(deviceInfo.Device)) + { + return entry.Value as string; + } + } + } + catch (Exception ex) + { + Log.Warning(ex, "[RealPluginManager] Error finding device token for {Device}", deviceInfo.Device?.DeviceName); + } + + return null; + } + + /// + /// 根据设备名称查找 DeviceInfo。 + /// + private DeviceInfo? FindDeviceInfoByName(string deviceName) + { + if (string.IsNullOrEmpty(deviceName)) + return null; + + try + { + // Use DevicesDiscoveryServer.DefaultDeviceInfo as reference for local device + // Try to find a matching device from the signed tokens + var tokensField = typeof(DevicesServer) + .GetField("_signedDeviceTokens", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + + if (tokensField == null) + return null; + + var tokens = tokensField.GetValue(DevicesServer.Instance) as System.Collections.IDictionary; + if (tokens == null) + return null; + + foreach (System.Collections.DictionaryEntry entry in tokens) + { + if (entry.Key is KitX.Shared.CSharp.Device.DeviceLocator locator && + locator.DeviceName.Equals(deviceName, StringComparison.OrdinalIgnoreCase)) + { + // Found the locator by name — now we need DeviceInfo which has the full info + // We can construct a basic DeviceInfo from the locator + DevicesDiscoveryServer data + var defaultInfo = KitX.Core.Device.DevicesDiscoveryServer.Instance?.DefaultDeviceInfo; + if (defaultInfo != null && defaultInfo.Device.IsSameDevice(locator)) + { + return defaultInfo; + } + + // Fallback: construct from locator alone (missing some fields) + return new DeviceInfo + { + Device = locator, + SendTime = DateTime.UtcNow + }; + } + } + } + catch (Exception ex) + { + Log.Warning(ex, "[RealPluginManager] Error finding DeviceInfo for {DeviceName}", deviceName); + } + + return null; + } + + /// + /// 向远端设备发送插件调用请求。 + /// + /// 插件调用信息 + /// 目标设备名称(DeviceLocator.DeviceName) + /// 调用结果(解析后的对象),失败返回 null + public object? RemoteCall(PluginCallInfo callInfo, string targetDeviceName) + { + const string location = $"{nameof(RealPluginManager)}.{nameof(RemoteCall)}"; + + if (string.IsNullOrEmpty(targetDeviceName)) + { + Log.Warning("[{Location}] RemoteCall: targetDeviceName is empty, falling back to local", location); + return CallAuto(callInfo); + } + + // 1. 查找目标 DeviceInfo + var deviceInfo = FindDeviceInfoByName(targetDeviceName); + if (deviceInfo == null) + { + Log.Error("[{Location}] RemoteCall: device not found or not connected: {DeviceName}", location, targetDeviceName); + throw new InvalidOperationException($"Device not found or not connected: {targetDeviceName}"); + } + + // 2. 查找 token + var token = FindDeviceToken(deviceInfo); + if (string.IsNullOrEmpty(token)) + { + Log.Error("[{Location}] RemoteCall: no session token for device {DeviceName}. " + + "Device must be connected via DevicesServer first.", location, targetDeviceName); + throw new InvalidOperationException($"Device not connected (no token): {targetDeviceName}"); + } + + // 3. 构建 Command + var command = new Command + { + Request = CommandRequestInfo.ReceiveCommand, + FunctionName = callInfo.MethodName, + PluginConnectionId = callInfo.PluginName, // 插件名称作为连接标识 + Tags = new System.Collections.Generic.Dictionary + { + ["RequestId"] = Guid.NewGuid().ToString() + } + }; + + // 4. 处理参数 + if (callInfo.Parameters != null && callInfo.Parameters.Length > 0) + { + command.FunctionArgs = new System.Collections.Generic.List(); + for (int i = 0; i < callInfo.Parameters.Length; i++) + { + var paramValue = callInfo.Parameters[i]?.ToString() ?? string.Empty; + var paramName = callInfo.ParameterNames?.Length > i ? callInfo.ParameterNames[i] : i.ToString(); + var paramType = callInfo.ParameterTypes?.Length > i ? callInfo.ParameterTypes[i].Name.ToLower() : "string"; + command.FunctionArgs.Add(new Parameter + { + Name = paramName, + Type = paramType, + Value = paramValue, + IsOptional = false + }); + } + command.Body = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(command.FunctionArgs, _serializerOptions)); + command.BodyLength = command.Body.Length; + } + + // 5. 构建 Request + var request = new Request + { + Type = RequestTypes.Command, + Version = RequestVersions.V1, + Target = deviceInfo.Device, + Content = JsonSerializer.Serialize(command, _serializerOptions) + }; + + // 6. 通过 DeviceHttpClient 发送 HTTP POST + Log.Information("[{Location}] RemoteCall: invoking {Plugin}.{Method} on device {Device}", + location, callInfo.PluginName, callInfo.MethodName, targetDeviceName); + + var response = _deviceHttpClient.InvokePluginAsync(deviceInfo, token, request).GetAwaiter().GetResult(); + if (response == null) + { + Log.Error("[{Location}] RemoteCall: HTTP request failed for {Plugin}.{Method}", + location, callInfo.PluginName, callInfo.MethodName); + throw new InvalidOperationException($"Failed to send request to device: {targetDeviceName}"); + } + + if (!response.IsSuccessStatusCode) + { + Log.Error("[{Location}] RemoteCall: HTTP {Status} from device {Device}: {Reason}", + location, response.StatusCode, targetDeviceName, response.ReasonPhrase); + throw new InvalidOperationException($"Remote invoke failed: HTTP {response.StatusCode}"); + } + + // 7. 读取响应内容 + var resultContent = response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + Log.Information("[{Location}] RemoteCall: received response from {Device}: {Content}", + location, targetDeviceName, resultContent.Length > 200 + ? resultContent.Substring(0, 200) + "..." + : resultContent); + + // 8. 解析返回值类型并转换 + var returnType = GetFunctionReturnType(callInfo.PluginName, callInfo.MethodName); + if (returnType == null || returnType == typeof(void)) + return null; + + return ParseResult(returnType, resultContent); + } + + /// + /// 向远端设备发送插件调用请求(异步版本)。 + /// + public async Task RemoteCallAsync(PluginCallInfo callInfo, string targetDeviceName, CancellationToken ct = default) + { + const string location = $"{nameof(RealPluginManager)}.{nameof(RemoteCallAsync)}"; + + if (string.IsNullOrEmpty(targetDeviceName)) + { + Log.Warning("[{Location}] RemoteCallAsync: targetDeviceName is empty, falling back to local", location); + return CallAuto(callInfo); + } + + var deviceInfo = FindDeviceInfoByName(targetDeviceName); + if (deviceInfo == null) + throw new InvalidOperationException($"Device not found: {targetDeviceName}"); + + var token = FindDeviceToken(deviceInfo); + if (string.IsNullOrEmpty(token)) + throw new InvalidOperationException($"Device not connected (no token): {targetDeviceName}"); + + var command = new Command + { + Request = CommandRequestInfo.ReceiveCommand, + FunctionName = callInfo.MethodName, + PluginConnectionId = callInfo.PluginName, + Tags = new System.Collections.Generic.Dictionary + { + ["RequestId"] = Guid.NewGuid().ToString() + } + }; + + if (callInfo.Parameters != null && callInfo.Parameters.Length > 0) + { + command.FunctionArgs = new System.Collections.Generic.List(); + for (int i = 0; i < callInfo.Parameters.Length; i++) + { + command.FunctionArgs.Add(new Parameter + { + Name = callInfo.ParameterNames?.Length > i ? callInfo.ParameterNames[i] : i.ToString(), + Type = callInfo.ParameterTypes?.Length > i ? callInfo.ParameterTypes[i].Name.ToLower() : "string", + Value = callInfo.Parameters[i]?.ToString() ?? string.Empty, + IsOptional = false + }); + } + command.Body = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(command.FunctionArgs, _serializerOptions)); + command.BodyLength = command.Body.Length; + } + + var request = new Request + { + Type = RequestTypes.Command, + Version = RequestVersions.V1, + Target = deviceInfo.Device, + Content = JsonSerializer.Serialize(command, _serializerOptions) + }; + + Log.Information("[{Location}] RemoteCallAsync: {Plugin}.{Method} on {Device}", + location, callInfo.PluginName, callInfo.MethodName, targetDeviceName); + + var response = await _deviceHttpClient.InvokePluginAsync(deviceInfo, token, request, ct); + if (response == null || !response.IsSuccessStatusCode) + { + var reason = response?.ReasonPhrase ?? "network error"; + Log.Error("[{Location}] RemoteCallAsync failed: HTTP {Status} from {Device}", + location, response?.StatusCode, targetDeviceName); + throw new InvalidOperationException($"Remote invoke failed: HTTP {response?.StatusCode}"); + } + + var resultContent = await response.Content.ReadAsStringAsync(ct); + var returnType = GetFunctionReturnType(callInfo.PluginName, callInfo.MethodName); + if (returnType == null || returnType == typeof(void)) + return null; + + return ParseResult(returnType, resultContent); + } + + /// + /// 解析插件调用返回值 + /// + private object? ParseResult(Type returnType, string result) + { + if (string.IsNullOrEmpty(result) || returnType == typeof(void)) + return null; + + try + { + if (returnType == typeof(string)) + return result; + if (returnType == typeof(int)) + return int.Parse(result); + if (returnType == typeof(long)) + return long.Parse(result); + if (returnType == typeof(float)) + return float.Parse(result); + if (returnType == typeof(double)) + return double.Parse(result); + if (returnType == typeof(bool)) + return bool.Parse(result); + if (returnType == typeof(object)) + { + try + { + return JsonSerializer.Deserialize(result, _serializerOptions) ?? result; + } + catch + { + return result; + } + } + return JsonSerializer.Deserialize(result, returnType, _serializerOptions); + } + catch (Exception ex) + { + Log.Warning(ex, "[RealPluginManager] Error parsing result as {Type}", returnType.Name); + return result; + } + } } diff --git a/KitX Clients/KitX Dashboard b/KitX Clients/KitX Dashboard index a333b99d..6087f854 160000 --- a/KitX Clients/KitX Dashboard +++ b/KitX Clients/KitX Dashboard @@ -1 +1 @@ -Subproject commit a333b99d229e71e7daa761cde39e47faad4c977e +Subproject commit 6087f854d692c95aaba3fee56c7c757656d714e1 diff --git a/KitX Standard b/KitX Standard index 8f2ee179..7d38df8e 160000 --- a/KitX Standard +++ b/KitX Standard @@ -1 +1 @@ -Subproject commit 8f2ee17923cc1d54025520ab268e351e181289e2 +Subproject commit 7d38df8ebe06054755fe678e4a5027520791abee From c9eb53885d247da3b0f673d9758fa3f4607410be Mon Sep 17 00:00:00 2001 From: StarInk Date: Sun, 19 Apr 2026 02:02:25 +0200 Subject: [PATCH 058/110] =?UTF-8?q?=F0=9F=A7=A9Refactor:=20=E6=9E=B6?= =?UTF-8?q?=E6=9E=84=E8=A7=84=E8=8C=83=E6=80=A7=E6=A2=B3=E7=90=86=E5=92=8C?= =?UTF-8?q?God=20Classes=E6=8B=86=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../KitX Core/KitX.Core.DI.Tests/Program.cs | 8 +- .../KitX.Core/Configuration/AppConfig.cs | 308 +--- .../KitX.Core/Configuration/ConfigLoader.cs | 120 ++ .../KitX.Core/Configuration/ConfigManager.cs | 224 +-- .../KitX.Core/Configuration/ConfigSaver.cs | 40 + .../Configuration/Config_Activity.cs | 11 + .../Config_AnnouncementWindow.cs | 31 + .../KitX.Core/Configuration/Config_App.cs | 44 + .../Configuration/Config_DevicePage.cs | 6 + .../Configuration/Config_HomePage.cs | 17 + .../KitX.Core/Configuration/Config_IO.cs | 13 + .../KitX.Core/Configuration/Config_Loaders.cs | 11 + .../KitX.Core/Configuration/Config_Log.cs | 30 + .../Configuration/Config_MainWindow.cs | 54 + .../Configuration/Config_MarketPage.cs | 6 + .../KitX.Core/Configuration/Config_Pages.cs | 23 + .../Configuration/Config_SettingsPage.cs | 33 + .../KitX.Core/Configuration/Config_Web.cs | 52 + .../KitX.Core/Configuration/Config_Windows.cs | 18 + .../DI/CoreServiceCollectionExtensions.cs | 99 +- .../KitX Core/KitX.Core/Device/DeviceCase.cs | 2 +- .../Device/DevicesDiscoveryServer.cs | 1 - .../KitX.Core/Device/DevicesOrganizer.cs | 21 - .../KitX.Core/Device/DevicesServer.cs | 182 +-- .../Device/Events/PluginEventArgs.cs | 42 + .../KitX.Core/Device/ExchangeKeyRequest.cs | 22 + .../KitX.Core/Device/IPluginConnection.cs | 52 + .../KitX.Core/Device/PluginConnection.cs | 159 ++ .../KitX.Core/Device/PluginsServer.cs | 240 +-- .../KitX.Core/Plugin/PluginConnector.cs | 308 ---- .../KitX.Core/Plugin/PluginInstallation.cs | 50 + .../KitX.Core/Plugin/PluginsManager.cs | 26 +- .../KitX.Core/Security/SecurityManager.cs | 12 +- .../Workflow/BlockScriptServiceImpl.cs | 215 +++ .../BlockScripting/CompiledScriptEntry.cs | 65 + .../BlockScripting/ScriptAssemblyCompiler.cs | 1375 +---------------- .../BlockScripting/ScriptCodeGenerator.cs | 884 +++++++++++ .../ScriptCompilationBackend.cs | 137 ++ .../ScriptPersistenceManager.cs | 224 +++ .../Workflow/Blueprint/INodeExportStrategy.cs | 75 - .../Workflow/ConstantValueRewriter.cs | 111 ++ .../KitX.Core/Workflow/RealPluginManager.cs | 3 + .../Workflow/ScriptExecutionService.cs | 319 ++++ .../KitX.Core/Workflow/TriggerManager.cs | 2 + .../KitX.Core/Workflow/WorkflowCase.cs | 34 + .../Workflow/WorkflowManagementService.cs | 223 +++ .../Workflow/WorkflowPluginService.cs | 178 +++ .../Workflow/WorkflowRuntimeState.cs | 45 + .../Workflow/WorkflowScriptService.cs | 1098 ++----------- KitX Clients/KitX Dashboard | 2 +- KitX Standard | 2 +- 51 files changed, 3464 insertions(+), 3793 deletions(-) create mode 100644 KitX Clients/KitX Core/KitX.Core/Configuration/ConfigLoader.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Configuration/ConfigSaver.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Configuration/Config_Activity.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Configuration/Config_AnnouncementWindow.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Configuration/Config_App.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Configuration/Config_DevicePage.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Configuration/Config_HomePage.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Configuration/Config_IO.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Configuration/Config_Loaders.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Configuration/Config_Log.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Configuration/Config_MainWindow.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Configuration/Config_MarketPage.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Configuration/Config_Pages.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Configuration/Config_SettingsPage.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Configuration/Config_Web.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Configuration/Config_Windows.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Device/Events/PluginEventArgs.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Device/ExchangeKeyRequest.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Device/IPluginConnection.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Device/PluginConnection.cs delete mode 100644 KitX Clients/KitX Core/KitX.Core/Plugin/PluginConnector.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Plugin/PluginInstallation.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/BlockScriptServiceImpl.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/CompiledScriptEntry.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptCodeGenerator.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptCompilationBackend.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptPersistenceManager.cs delete mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/INodeExportStrategy.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/ConstantValueRewriter.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/ScriptExecutionService.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowCase.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowManagementService.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowPluginService.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowRuntimeState.cs diff --git a/KitX Clients/KitX Core/KitX.Core.DI.Tests/Program.cs b/KitX Clients/KitX Core/KitX.Core.DI.Tests/Program.cs index f242375a..8e208e52 100644 --- a/KitX Clients/KitX Core/KitX.Core.DI.Tests/Program.cs +++ b/KitX Clients/KitX Core/KitX.Core.DI.Tests/Program.cs @@ -54,9 +54,13 @@ private static void TestServiceResolution() Console.WriteLine("Testing service resolution:\n"); TestService(serviceProvider, "IConfigService"); - TestService(serviceProvider, "ISecurityService"); + TestService(serviceProvider, "IDeviceKeyService"); + TestService(serviceProvider, "IEncryptionService"); TestService(serviceProvider, "IPluginService"); - TestService(serviceProvider, "IWorkflowService"); + TestService(serviceProvider, "IWorkflowManagementService"); + TestService(serviceProvider, "IScriptExecutionService"); + TestService(serviceProvider, "IWorkflowPluginService"); + TestService(serviceProvider, "IBlockScriptService"); TestService(serviceProvider, "IActivityService"); TestService(serviceProvider, "IStatisticsService"); TestService(serviceProvider, "ITasksService"); diff --git a/KitX Clients/KitX Core/KitX.Core/Configuration/AppConfig.cs b/KitX Clients/KitX Core/KitX.Core/Configuration/AppConfig.cs index dca9f80c..e7eedd8d 100644 --- a/KitX Clients/KitX Core/KitX.Core/Configuration/AppConfig.cs +++ b/KitX Clients/KitX Core/KitX.Core/Configuration/AppConfig.cs @@ -1,5 +1,4 @@ -using System; -using System.Collections.Generic; +using System; using Common.BasicHelper.Graphics.Screen; using KitX.Core.Contract.Configuration; using Serilog.Events; @@ -52,309 +51,4 @@ public class AppConfig : IAppConfig, IConfigWithMetadata IIOConf IAppConfig.IO { get => IO; set => IO = (Config_IO?)value ?? new(); } IActivityConf IAppConfig.Activity { get => Activity; set => Activity = (Config_Activity?)value ?? new(); } ILoadersConf IAppConfig.Loaders { get => Loaders; set => Loaders = (Config_Loaders?)value ?? new(); } - - /// - /// Application configuration section - /// - public class Config_App : IAppConf - { - public string IconFileName { get; set; } = "KitX-Icon-1920x-margin-2x.png"; - - public string CoverIconFileName { get; set; } = "KitX-Icon-Background.png"; - - public string AppLanguage { get; set; } = "zh-cn"; - - public string Theme { get; set; } = "Follow"; - - public string ThemeColor { get; set; } = "#FF3873D9"; - - public Dictionary SurpportLanguages { get; set; } = - new() - { - { "zh-cn", "中文 (简体)" }, - { "zh-tw", "中文 (繁體)" }, - { "ru-ru", "Русский" }, - { "en-us", "English (US)" }, - { "fr-fr", "Français" }, - { "ja-jp", "日本語" }, - { "ko-kr", "한국어" }, - }; - - public string LocalPluginsFileFolder { get; set; } = "./Plugins/"; - - public string LocalPluginsDataFolder { get; set; } = "./PluginsDatas/"; - - public bool DeveloperSetting { get; set; } = false; - - public bool ShowAnnouncementWhenStart { get; set; } = true; - - public ulong RanTime { get; set; } = 0; - - public int LastBreakAfterExit { get; set; } = 2000; - } - - /// - /// Windows configuration section - /// - public class Config_Windows : IWindowsConf - { - public Config_MainWindow MainWindow { get; set; } = new(); - - public Config_AnnouncementWindow AnnouncementWindow { get; set; } = new(); - - // Explicit interface implementation with setters - IMainWindowConf IWindowsConf.MainWindow { get => MainWindow; set => MainWindow = (Config_MainWindow?)value ?? new(); } - IAnnouncementWindowConf IWindowsConf.AnnouncementWindow { get => AnnouncementWindow; set => AnnouncementWindow = (Config_AnnouncementWindow?)value ?? new(); } - - /// - /// Main window configuration - /// - public class Config_MainWindow : IMainWindowConf - { - private Resolution _size = Resolution.Parse("1280x720"); - private Distances _location = new(left: -1, top: -1); - - /// - /// Window size (strong type) - /// - public Resolution Size - { - get => _size; - set => _size = value; - } - - /// - /// Window location (strong type) - /// - public Distances Location - { - get => _location; - set => _location = value; - } - - /// - /// Window state (strong type) - /// - public WindowState WindowState { get; set; } = WindowState.Normal; - - public bool IsHidden { get; set; } = false; - - public Dictionary Tags { get; set; } = new() { { "SelectedPage", "Page_Home" } }; - - public bool EnabledMica { get; set; } = true; - - public int GreetingTextCount_Morning { get; set; } = 5; - - public int GreetingTextCount_Noon { get; set; } = 3; - - public int GreetingTextCount_AfterNoon { get; set; } = 3; - - public int GreetingTextCount_Evening { get; set; } = 2; - - public int GreetingTextCount_Night { get; set; } = 4; - - public int GreetingUpdateInterval { get; set; } = 10; - } - - /// - /// Announcement window configuration - /// - public class Config_AnnouncementWindow : IAnnouncementWindowConf - { - private Resolution _size = Resolution.Parse("1280x720"); - private Distances _location = new(left: -1, top: -1); - - /// - /// Window size (strong type) - /// - public Resolution Size - { - get => _size; - set => _size = value; - } - - /// - /// Window location (strong type) - /// - public Distances Location - { - get => _location; - set => _location = value; - } - } - } - - /// - /// Pages configuration section - /// - public class Config_Pages : IPagesConf - { - public Config_HomePage Home { get; set; } = new(); - - public Config_DevicePage Device { get; set; } = new(); - - public Config_MarketPage Market { get; set; } = new(); - - public Config_SettingsPage Settings { get; set; } = new(); - - // Explicit interface implementation with setters - IHomePageConf IPagesConf.Home { get => Home; set => Home = (Config_HomePage?)value ?? new(); } - IDevicePageConf IPagesConf.Device { get => Device; set => Device = (Config_DevicePage?)value ?? new(); } - IMarketPageConf IPagesConf.Market { get => Market; set => Market = (Config_MarketPage?)value ?? new(); } - ISettingsPageConf IPagesConf.Settings { get => Settings; set => Settings = (Config_SettingsPage?)value ?? new(); } - - /// - /// Home page configuration - /// - public class Config_HomePage : IHomePageConf - { - public NavigationViewPaneDisplayMode NavigationViewPaneDisplayMode { get; set; } = NavigationViewPaneDisplayMode.Auto; - - public string SelectedViewName { get; set; } = "View_Recent"; - - public bool IsNavigationViewPaneOpened { get; set; } = true; - - public bool UseAreaExpanded { get; set; } = true; - } - - /// - /// Device page configuration - /// - public class Config_DevicePage : IDevicePageConf { } - - /// - /// Market page configuration - /// - public class Config_MarketPage : IMarketPageConf { } - - /// - /// Settings page configuration - /// - public class Config_SettingsPage : ISettingsPageConf - { - public NavigationViewPaneDisplayMode NavigationViewPaneDisplayMode { get; set; } = NavigationViewPaneDisplayMode.Auto; - - public string SelectedViewName { get; set; } = "View_General"; - - public bool PaletteAreaExpanded { get; set; } = false; - - public bool WebRelatedAreaExpanded { get; set; } = true; - - public bool WebRelatedAreaOfNetworkInterfacesExpanded { get; set; } = false; - - public bool LogRelatedAreaExpanded { get; set; } = true; - - public bool UpdateRelatedAreaExpanded { get; set; } = true; - - public bool AboutAreaExpanded { get; set; } = false; - - public bool AuthorsAreaExpanded { get; set; } = false; - - public bool LinksAreaExpanded { get; set; } = false; - - public bool ThirdPartyLicensesAreaExpanded { get; set; } = false; - - public bool IsNavigationViewPaneOpened { get; set; } = true; - } - } - - /// - /// Web configuration section - /// - public class Config_Web : IWebConf - { - public double DelayStartSeconds { get; set; } = 0.5; - - public string ApiServer { get; set; } = "api.catrol.cn"; - - public string ApiPath { get; set; } = "/apps/kitx/"; - - public int DevicesViewRefreshDelay { get; set; } = 1000; - - public List? AcceptedNetworkInterfaces { get; set; } = null; - - public int? UserSpecifiedDevicesServerPort { get; set; } = null; - - public int? UserSpecifiedPluginsServerPort { get; set; } = null; - - public int UdpPortSend { get; set; } = 23404; - - public int UdpPortReceive { get; set; } = 24040; - - public int UdpSendFrequency { get; set; } = 1000; - - public string UdpBroadcastAddress { get; set; } = "224.0.0.0"; - - public string IPFilter { get; set; } = "192.168"; - - public int SocketBufferSize { get; set; } = 1024 * 100; - - public int DeviceInfoTTLSeconds { get; set; } = 7; - - public bool DisableRemovingOfflineDeviceCard { get; set; } = false; - - public string UpdateServer { get; set; } = "api.catrol.cn"; - - public string UpdatePath { get; set; } = "/apps/kitx/%platform%/"; - - public string UpdateDownloadPath { get; set; } = "/apps/kitx/update/%platform%/"; - - public string UpdateChannel { get; set; } = "stable"; - - public string UpdateSource { get; set; } = "latest-components.json"; - - public int DebugServicesServerPort { get; set; } = 7777; - } - - /// - /// Log configuration section - /// - public class Config_Log : ILogConf - { - public long LogFileSingleMaxSize { get; set; } = 1024 * 1024 * 10; // 10MB - - public string LogFilePath { get; set; } = "./Log/"; - - public string LogTemplate { get; set; } = "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] {Message:lj}{NewLine}{Exception}"; - - public int LogFileMaxCount { get; set; } = 50; - - public int LogFileFlushInterval { get; set; } = 30; - -#if DEBUG - - public LogEventLevel LogLevel { get; set; } = LogEventLevel.Information; - -#else - - public LogEventLevel LogLevel { get; set; } = LogEventLevel.Warning; - -#endif - } - - /// - /// IO configuration section - /// - public class Config_IO : IIOConf - { - public int UpdatingCheckPerThreadFilesCount { get; set; } = 20; - - public int OperatingSystemVersionUpdateInterval { get; set; } = 60; - } - - /// - /// Activity configuration section - /// - public class Config_Activity : IActivityConf - { - public int TotalRecorded { get; set; } = 0; - } - - /// - /// Loaders configuration section - /// - public class Config_Loaders : ILoadersConf - { - public string InstallPath { get; set; } = "./Loaders/"; - } } diff --git a/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigLoader.cs b/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigLoader.cs new file mode 100644 index 00000000..65fba9d4 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigLoader.cs @@ -0,0 +1,120 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using KitX.Core.Contract.Configuration; +using KitX.Shared.CSharp.Device; +using Serilog; + +namespace KitX.Core.Configuration; + +/// +/// Loads configuration from files +/// +public class ConfigLoader : IConfigLoader +{ + /// + public T Load(string location, string fileName) where T : class, new() + { + var path = Path.Combine(location, fileName); + + if (!File.Exists(path)) + { + Log.Warning("Config file {FileName} not found, creating default", fileName); + return new T(); + } + + try + { + var json = File.ReadAllText(path); + var config = JsonSerializer.Deserialize(json, ConfigSerializationOptions.Options); + return config ?? new T(); + } + catch (Exception ex) + { + Log.Error(ex, "Error loading config file {FileName}: {Message}", fileName, ex.Message); + return new T(); + } + } + + /// + public ISecurityConfig LoadSecurityConfig(string location) + { + var path = Path.Combine(location, "SecurityConfig.json"); + + if (!File.Exists(path)) + { + Log.Warning("SecurityConfig.json not found, creating default"); + return new SecurityConfig(); + } + + try + { + var json = File.ReadAllText(path); + return DeserializeSecurityConfig(json); + } + catch (Exception ex) + { + Log.Error(ex, "Error loading SecurityConfig: {Message}", ex.Message); + return new SecurityConfig(); + } + } + + private static ISecurityConfig DeserializeSecurityConfig(string json) + { + try + { + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + var config = new SecurityConfig(); + + if (root.TryGetProperty("ConfigFileLocation", out var configFileLocation)) + config.ConfigFileLocation = configFileLocation.GetString(); + if (root.TryGetProperty("ConfigFileWatcherName", out var configFileWatcherName)) + config.ConfigFileWatcherName = configFileWatcherName.GetString(); + if (root.TryGetProperty("ConfigGeneratedTime", out var configGeneratedTime)) + if (DateTime.TryParse(configGeneratedTime.GetString(), out var generatedTime)) + config.ConfigGeneratedTime = generatedTime; + + if (root.TryGetProperty("DeviceKeys", out var deviceKeysElement)) + { + var deviceKeys = new List(); + + foreach (var keyElement in deviceKeysElement.EnumerateArray()) + { + var impl = new DeviceKeyImpl(); + + if (keyElement.TryGetProperty("Device", out var deviceElement)) + { + impl.Device = new DeviceLocator + { + DeviceName = deviceElement.TryGetProperty("DeviceName", out var dn) ? dn.GetString() ?? "" : "", + IPv4 = deviceElement.TryGetProperty("IPv4", out var ipv4) ? ipv4.GetString() ?? "" : "", + IPv6 = deviceElement.TryGetProperty("IPv6", out var ipv6) ? ipv6.GetString() ?? "" : "", + MacAddress = deviceElement.TryGetProperty("MacAddress", out var mac) ? mac.GetString() ?? "" : "" + }; + } + + impl.RsaPublicKeyPem = keyElement.TryGetProperty("RsaPublicKeyPem", out var pubKey) ? pubKey.GetString() : null; + impl.RsaPrivateKeyPem = keyElement.TryGetProperty("RsaPrivateKeyPem", out var privKey) ? privKey.GetString() : null; + + if (keyElement.TryGetProperty("AddedAt", out var addedAtElement)) + if (DateTime.TryParse(addedAtElement.GetString(), out var addedAt)) + impl.AddedAt = addedAt; + + deviceKeys.Add(impl); + } + + config.DeviceKeys = deviceKeys; + } + + return config; + } + catch (Exception ex) + { + Log.Error(ex, "Error deserializing SecurityConfig: {Message}", ex.Message); + return new SecurityConfig(); + } + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigManager.cs b/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigManager.cs index bfba77f6..e17739ef 100644 --- a/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigManager.cs @@ -1,16 +1,14 @@ -using System; +using System; using System.Collections.Generic; using System.IO; -using System.Linq; -using System.Text.Json; using KitX.Core.Contract.Configuration; -using KitX.Shared.CSharp.Device; using Serilog; namespace KitX.Core.Configuration; /// /// Configuration manager for managing application configurations +/// Coordinates ConfigLoader, ConfigSaver, and file watching /// public class ConfigManager : IConfigService, IDisposable { @@ -35,6 +33,9 @@ public class ConfigManager : IConfigService, IDisposable /// private readonly Dictionary _exceptCounts = new(); + private readonly IConfigLoader _loader; + private readonly IConfigSaver _saver; + /// /// Whether hot-reload is enabled /// @@ -80,7 +81,11 @@ public class ConfigManager : IConfigService, IDisposable /// /// Private constructor /// - private ConfigManager() { } + private ConfigManager() + { + _loader = new ConfigLoader(); + _saver = new ConfigSaver(); + } /// /// Sets the configuration file location @@ -109,11 +114,14 @@ public void Load() SetLocation("./Config/"); } - LoadConfigFile("AppConfig.json"); - LoadConfigFile("PluginsConfig.json"); - LoadConfigFile("SecurityConfig.json"); + AppConfig = _loader.Load(_configLocation, "AppConfig.json"); + PluginsConfig = _loader.Load(_configLocation, "PluginsConfig.json"); + SecurityConfig = _loader.LoadSecurityConfig(_configLocation); + + _configs["AppConfig"] = AppConfig; + _configs["PluginsConfig"] = PluginsConfig; + _configs["SecurityConfig"] = SecurityConfig; - // Register file watchers for hot-reload if enabled if (HotReloadEnabled) { RegisterFileWatcher("AppConfig.json"); @@ -125,16 +133,13 @@ public void Load() /// /// Registers a file watcher for a config file to enable hot-reload /// - private void RegisterFileWatcher(string fileName) where T : class + private void RegisterFileWatcher(string fileName) where T : class, new() { var watcherName = $"ConfigFileWatcher_{typeof(T).Name}"; var path = Path.Combine(_configLocation!, fileName); if (_fileWatchers.ContainsKey(watcherName)) - { - // Already registered return; - } var directory = Path.GetDirectoryName(path); var filter = Path.GetFileName(path); @@ -151,7 +156,6 @@ private void RegisterFileWatcher(string fileName) where T : class watcher.Changed += (sender, args) => { - // Use ExceptCount to prevent infinite loops if (_exceptCounts.TryGetValue(watcherName, out var count) && count > 0) { _exceptCounts[watcherName] = count - 1; @@ -164,7 +168,6 @@ private void RegisterFileWatcher(string fileName) where T : class try { - // Reload the config file directly - read from file and update the stored config ReloadConfigFile(fileName); OnConfigChanged(typeof(T).Name, "FileChanged", null, null); } @@ -181,9 +184,9 @@ private void RegisterFileWatcher(string fileName) where T : class } /// - /// Reloads a single config file from disk (used by file watcher) + /// Reloads a single config file from disk /// - private void ReloadConfigFile(string fileName) where T : class + private void ReloadConfigFile(string fileName) where T : class, new() { var path = Path.Combine(_configLocation!, fileName); @@ -193,25 +196,14 @@ private void ReloadConfigFile(string fileName) where T : class return; } - var json = File.ReadAllText(path); - object? config = null; - - // Special handling for SecurityConfig to deserialize correctly - if (typeof(T) == typeof(SecurityConfig)) - { - config = DeserializeSecurityConfig(json); - } - else - { - // Use ConfigBase for polymorphic deserialization - config = JsonSerializer.Deserialize(json, ConfigSerializationOptions.Options); - } + object? config = typeof(T) == typeof(SecurityConfig) + ? _loader.LoadSecurityConfig(_configLocation) + : _loader.Load(_configLocation, fileName); if (config != null) { _configs[typeof(T).Name] = config; ApplyConfig(config); - Log.Information("Reloaded config file {FileName}", fileName); } } @@ -222,13 +214,9 @@ private void ReloadConfigFile(string fileName) where T : class public void IncreaseExceptCount(string watcherName, int count = 1) { if (_exceptCounts.TryGetValue(watcherName, out var current)) - { _exceptCounts[watcherName] = current + count; - } else - { _exceptCounts[watcherName] = count; - } } /// @@ -237,9 +225,7 @@ public void IncreaseExceptCount(string watcherName, int count = 1) public void DecreaseExceptCount(string watcherName, int count = 1) { if (_exceptCounts.TryGetValue(watcherName, out var current)) - { _exceptCounts[watcherName] = Math.Max(0, current - count); - } } /// @@ -247,82 +233,17 @@ public void DecreaseExceptCount(string watcherName, int count = 1) /// public void SaveAll() { - SaveConfigFile((AppConfig)AppConfig, "AppConfig.json"); - SaveConfigFile((PluginsConfig)PluginsConfig, "PluginsConfig.json"); - SaveConfigFile((SecurityConfig)SecurityConfig, "SecurityConfig.json"); - } - - /// - /// Deserializes security config from JSON with proper structure - /// - private SecurityConfig DeserializeSecurityConfig(string json) - { - try - { - using var doc = JsonDocument.Parse(json); - var root = doc.RootElement; + var watcherName = "ConfigFileWatcher_AppConfig"; + IncreaseExceptCount(watcherName, 2); + _saver.Save(AppConfig, _configLocation!, "AppConfig.json"); - var config = new SecurityConfig(); + watcherName = "ConfigFileWatcher_PluginsConfig"; + IncreaseExceptCount(watcherName, 2); + _saver.Save(PluginsConfig, _configLocation!, "PluginsConfig.json"); - // Parse metadata fields - if (root.TryGetProperty("ConfigFileLocation", out var configFileLocation)) - { - config.ConfigFileLocation = configFileLocation.GetString(); - } - if (root.TryGetProperty("ConfigFileWatcherName", out var configFileWatcherName)) - { - config.ConfigFileWatcherName = configFileWatcherName.GetString(); - } - if (root.TryGetProperty("ConfigGeneratedTime", out var configGeneratedTime)) - { - if (DateTime.TryParse(configGeneratedTime.GetString(), out var generatedTime)) - config.ConfigGeneratedTime = generatedTime; - } - - if (root.TryGetProperty("DeviceKeys", out var deviceKeysElement)) - { - var deviceKeys = new List(); - - foreach (var keyElement in deviceKeysElement.EnumerateArray()) - { - var impl = new DeviceKeyImpl(); - - // Parse Device object - if (keyElement.TryGetProperty("Device", out var deviceElement)) - { - impl.Device = new DeviceLocator - { - DeviceName = deviceElement.TryGetProperty("DeviceName", out var dn) ? dn.GetString() ?? "" : "", - IPv4 = deviceElement.TryGetProperty("IPv4", out var ipv4) ? ipv4.GetString() ?? "" : "", - IPv6 = deviceElement.TryGetProperty("IPv6", out var ipv6) ? ipv6.GetString() ?? "" : "", - MacAddress = deviceElement.TryGetProperty("MacAddress", out var mac) ? mac.GetString() ?? "" : "" - }; - } - - // Parse RSA keys - impl.RsaPublicKeyPem = keyElement.TryGetProperty("RsaPublicKeyPem", out var pubKey) ? pubKey.GetString() : null; - impl.RsaPrivateKeyPem = keyElement.TryGetProperty("RsaPrivateKeyPem", out var privKey) ? privKey.GetString() : null; - - // Parse AddedAt - if (keyElement.TryGetProperty("AddedAt", out var addedAtElement)) - { - if (DateTime.TryParse(addedAtElement.GetString(), out var addedAt)) - impl.AddedAt = addedAt; - } - - deviceKeys.Add(impl); - } - - config.DeviceKeys = deviceKeys; - } - - return config; - } - catch (Exception ex) - { - Log.Error(ex, "Error deserializing SecurityConfig: {Message}", ex.Message); - return new SecurityConfig(); - } + watcherName = "ConfigFileWatcher_SecurityConfig"; + IncreaseExceptCount(watcherName, 2); + _saver.Save(SecurityConfig, _configLocation!, "SecurityConfig.json"); } /// @@ -333,51 +254,6 @@ public void Reload() Load(); } - private void LoadConfigFile(string fileName) where T : class, new() - { - try - { - var path = Path.Combine(_configLocation!, fileName); - - if (File.Exists(path)) - { - var json = File.ReadAllText(path); - object? config = null; - - // Special handling for SecurityConfig to deserialize correctly - if (typeof(T) == typeof(SecurityConfig)) - { - config = DeserializeSecurityConfig(json); - } - else - { - // Use ConfigBase for polymorphic deserialization - config = JsonSerializer.Deserialize(json, ConfigSerializationOptions.Options); - } - - if (config != null) - { - _configs[typeof(T).Name] = config; - ApplyConfig(config); - } - } - else - { - // Create default config - var config = new T(); - _configs[typeof(T).Name] = config; - - // Save default config - SaveConfigFile(config, fileName); - ApplyConfig(config); - } - } - catch (Exception ex) - { - Log.Error(ex, $"Error loading config file {fileName}: {ex.Message}"); - } - } - /// /// Updates the public config properties based on the config object's type /// @@ -391,44 +267,9 @@ private void ApplyConfig(object config) SecurityConfig = securityConfig; } - /// - /// Saves config file - /// - private void SaveConfigFile(T config, string fileName) where T : class - { - try - { - var path = Path.Combine(_configLocation!, fileName); - - // Increase ExceptCount to prevent file change event from triggering reload - var watcherName = $"ConfigFileWatcher_{typeof(T).Name}"; - IncreaseExceptCount(watcherName, 2); - - // Update metadata fields before serialization - if (config is IConfigWithMetadata metadata) - { - metadata.ConfigFileLocation = path; - metadata.ConfigFileWatcherName = watcherName; - metadata.ConfigGeneratedTime = DateTime.Now; - } - - // Serialize the config object directly - var jsonContent = JsonSerializer.Serialize(config, ConfigSerializationOptions.Options); - File.WriteAllText(path, jsonContent); - } - catch (Exception ex) - { - Log.Error(ex, $"Error saving config file {fileName}: {ex.Message}"); - } - } - /// /// Raises the config changed event /// - /// The configuration type - /// The property name that changed - /// The old value - /// The new value protected void OnConfigChanged(string configType, string propertyName, object? oldValue = null, object? newValue = null) { ConfigChanged?.Invoke(this, new ConfigChangedEventArgs @@ -445,7 +286,6 @@ protected void OnConfigChanged(string configType, string propertyName, object? o /// public void Dispose() { - // Dispose all file watchers foreach (var watcher in _fileWatchers.Values) { watcher.EnableRaisingEvents = false; diff --git a/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigSaver.cs b/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigSaver.cs new file mode 100644 index 00000000..842ee69b --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigSaver.cs @@ -0,0 +1,40 @@ +using System; +using System.IO; +using System.Text.Json; +using KitX.Core.Contract.Configuration; +using Serilog; + +namespace KitX.Core.Configuration; + +/// +/// Saves configuration to files +/// +public class ConfigSaver : IConfigSaver +{ + /// + public void Save(T config, string location, string fileName) where T : class + { + try + { + var path = Path.Combine(location, fileName); + + // Update metadata fields before serialization + if (config is IConfigWithMetadata metadata) + { + var watcherName = $"ConfigFileWatcher_{typeof(T).Name}"; + metadata.ConfigFileLocation = path; + metadata.ConfigFileWatcherName = watcherName; + metadata.ConfigGeneratedTime = DateTime.Now; + } + + var jsonContent = JsonSerializer.Serialize(config, ConfigSerializationOptions.Options); + File.WriteAllText(path, jsonContent); + + Log.Debug("Saved config file {FileName}", fileName); + } + catch (Exception ex) + { + Log.Error(ex, "Error saving config file {FileName}: {Message}", fileName, ex.Message); + } + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Configuration/Config_Activity.cs b/KitX Clients/KitX Core/KitX.Core/Configuration/Config_Activity.cs new file mode 100644 index 00000000..246ef7d9 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Configuration/Config_Activity.cs @@ -0,0 +1,11 @@ +using KitX.Core.Contract.Configuration; + +namespace KitX.Core.Configuration; + +/// +/// Activity configuration section +/// +public class Config_Activity : IActivityConf +{ + public int TotalRecorded { get; set; } = 0; +} \ No newline at end of file diff --git a/KitX Clients/KitX Core/KitX.Core/Configuration/Config_AnnouncementWindow.cs b/KitX Clients/KitX Core/KitX.Core/Configuration/Config_AnnouncementWindow.cs new file mode 100644 index 00000000..85b3ac8b --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Configuration/Config_AnnouncementWindow.cs @@ -0,0 +1,31 @@ +using Common.BasicHelper.Graphics.Screen; +using KitX.Core.Contract.Configuration; + +namespace KitX.Core.Configuration; + +/// +/// Announcement window configuration +/// +public class Config_AnnouncementWindow : IAnnouncementWindowConf +{ + private Resolution _size = Resolution.Parse("1280x720"); + private Distances _location = new(left: -1, top: -1); + + /// + /// Window size (strong type) + /// + public Resolution Size + { + get => _size; + set => _size = value; + } + + /// + /// Window location (strong type) + /// + public Distances Location + { + get => _location; + set => _location = value; + } +} \ No newline at end of file diff --git a/KitX Clients/KitX Core/KitX.Core/Configuration/Config_App.cs b/KitX Clients/KitX Core/KitX.Core/Configuration/Config_App.cs new file mode 100644 index 00000000..f1d76f3c --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Configuration/Config_App.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using KitX.Core.Contract.Configuration; + +namespace KitX.Core.Configuration; + +/// +/// Application configuration section +/// +public class Config_App : IAppConf +{ + public string IconFileName { get; set; } = "KitX-Icon-1920x-margin-2x.png"; + + public string CoverIconFileName { get; set; } = "KitX-Icon-Background.png"; + + public string AppLanguage { get; set; } = "zh-cn"; + + public string Theme { get; set; } = "Follow"; + + public string ThemeColor { get; set; } = "#FF3873D9"; + + public Dictionary SurpportLanguages { get; set; } = + new() + { + { "zh-cn", "中文 (简体)" }, + { "zh-tw", "中文 (繁體)" }, + { "ru-ru", "Русский" }, + { "en-us", "English (US)" }, + { "fr-fr", "Français" }, + { "ja-jp", "日本語" }, + { "ko-kr", "한국어" }, + }; + + public string LocalPluginsFileFolder { get; set; } = "./Plugins/"; + + public string LocalPluginsDataFolder { get; set; } = "./PluginsDatas/"; + + public bool DeveloperSetting { get; set; } = false; + + public bool ShowAnnouncementWhenStart { get; set; } = true; + + public ulong RanTime { get; set; } = 0; + + public int LastBreakAfterExit { get; set; } = 2000; +} \ No newline at end of file diff --git a/KitX Clients/KitX Core/KitX.Core/Configuration/Config_DevicePage.cs b/KitX Clients/KitX Core/KitX.Core/Configuration/Config_DevicePage.cs new file mode 100644 index 00000000..1ddc414e --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Configuration/Config_DevicePage.cs @@ -0,0 +1,6 @@ +namespace KitX.Core.Configuration; + +/// +/// Device page configuration +/// +public class Config_DevicePage { } \ No newline at end of file diff --git a/KitX Clients/KitX Core/KitX.Core/Configuration/Config_HomePage.cs b/KitX Clients/KitX Core/KitX.Core/Configuration/Config_HomePage.cs new file mode 100644 index 00000000..614beb01 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Configuration/Config_HomePage.cs @@ -0,0 +1,17 @@ +using KitX.Core.Contract.Configuration; + +namespace KitX.Core.Configuration; + +/// +/// Home page configuration +/// +public class Config_HomePage : IHomePageConf +{ + public NavigationViewPaneDisplayMode NavigationViewPaneDisplayMode { get; set; } = NavigationViewPaneDisplayMode.Auto; + + public string SelectedViewName { get; set; } = "View_Recent"; + + public bool IsNavigationViewPaneOpened { get; set; } = true; + + public bool UseAreaExpanded { get; set; } = true; +} \ No newline at end of file diff --git a/KitX Clients/KitX Core/KitX.Core/Configuration/Config_IO.cs b/KitX Clients/KitX Core/KitX.Core/Configuration/Config_IO.cs new file mode 100644 index 00000000..a0dce008 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Configuration/Config_IO.cs @@ -0,0 +1,13 @@ +using KitX.Core.Contract.Configuration; + +namespace KitX.Core.Configuration; + +/// +/// IO configuration section +/// +public class Config_IO : IIOConf +{ + public int UpdatingCheckPerThreadFilesCount { get; set; } = 20; + + public int OperatingSystemVersionUpdateInterval { get; set; } = 60; +} \ No newline at end of file diff --git a/KitX Clients/KitX Core/KitX.Core/Configuration/Config_Loaders.cs b/KitX Clients/KitX Core/KitX.Core/Configuration/Config_Loaders.cs new file mode 100644 index 00000000..8008bf7b --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Configuration/Config_Loaders.cs @@ -0,0 +1,11 @@ +using KitX.Core.Contract.Configuration; + +namespace KitX.Core.Configuration; + +/// +/// Loaders configuration section +/// +public class Config_Loaders : ILoadersConf +{ + public string InstallPath { get; set; } = "./Loaders/"; +} \ No newline at end of file diff --git a/KitX Clients/KitX Core/KitX.Core/Configuration/Config_Log.cs b/KitX Clients/KitX Core/KitX.Core/Configuration/Config_Log.cs new file mode 100644 index 00000000..fbd3d0a1 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Configuration/Config_Log.cs @@ -0,0 +1,30 @@ +using KitX.Core.Contract.Configuration; +using Serilog.Events; + +namespace KitX.Core.Configuration; + +/// +/// Log configuration section +/// +public class Config_Log : ILogConf +{ + public long LogFileSingleMaxSize { get; set; } = 1024 * 1024 * 10; // 10MB + + public string LogFilePath { get; set; } = "./Log/"; + + public string LogTemplate { get; set; } = "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] {Message:lj}{NewLine}{Exception}"; + + public int LogFileMaxCount { get; set; } = 50; + + public int LogFileFlushInterval { get; set; } = 30; + +#if DEBUG + + public LogEventLevel LogLevel { get; set; } = LogEventLevel.Information; + +#else + + public LogEventLevel LogLevel { get; set; } = LogEventLevel.Warning; + +#endif +} \ No newline at end of file diff --git a/KitX Clients/KitX Core/KitX.Core/Configuration/Config_MainWindow.cs b/KitX Clients/KitX Core/KitX.Core/Configuration/Config_MainWindow.cs new file mode 100644 index 00000000..7e89ba54 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Configuration/Config_MainWindow.cs @@ -0,0 +1,54 @@ +using Common.BasicHelper.Graphics.Screen; +using KitX.Core.Contract.Configuration; + +namespace KitX.Core.Configuration; + +/// +/// Main window configuration +/// +public class Config_MainWindow : IMainWindowConf +{ + private Resolution _size = Resolution.Parse("1280x720"); + private Distances _location = new(left: -1, top: -1); + + /// + /// Window size (strong type) + /// + public Resolution Size + { + get => _size; + set => _size = value; + } + + /// + /// Window location (strong type) + /// + public Distances Location + { + get => _location; + set => _location = value; + } + + /// + /// Window state (strong type) + /// + public WindowState WindowState { get; set; } = WindowState.Normal; + + public bool IsHidden { get; set; } = false; + + public Dictionary Tags { get; set; } = new() { { "SelectedPage", "Page_Home" } }; + + public bool EnabledMica { get; set; } = true; + + public int GreetingTextCount_Morning { get; set; } = 5; + + public int GreetingTextCount_Noon { get; set; } = 3; + + public int GreetingTextCount_AfterNoon { get; set; } = 3; + + public int GreetingTextCount_Evening { get; set; } = 2; + + public int GreetingTextCount_Night { get; set; } = 4; + + public int GreetingUpdateInterval { get; set; } = 10; +} \ No newline at end of file diff --git a/KitX Clients/KitX Core/KitX.Core/Configuration/Config_MarketPage.cs b/KitX Clients/KitX Core/KitX.Core/Configuration/Config_MarketPage.cs new file mode 100644 index 00000000..cdbfe5af --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Configuration/Config_MarketPage.cs @@ -0,0 +1,6 @@ +namespace KitX.Core.Configuration; + +/// +/// Market page configuration +/// +public class Config_MarketPage { } \ No newline at end of file diff --git a/KitX Clients/KitX Core/KitX.Core/Configuration/Config_Pages.cs b/KitX Clients/KitX Core/KitX.Core/Configuration/Config_Pages.cs new file mode 100644 index 00000000..c9177c7c --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Configuration/Config_Pages.cs @@ -0,0 +1,23 @@ +using KitX.Core.Contract.Configuration; + +namespace KitX.Core.Configuration; + +/// +/// Pages configuration section +/// +public class Config_Pages : IPagesConf +{ + public Config_HomePage Home { get; set; } = new(); + + public Config_DevicePage Device { get; set; } = new(); + + public Config_MarketPage Market { get; set; } = new(); + + public Config_SettingsPage Settings { get; set; } = new(); + + // Explicit interface implementation with setters + IHomePageConf IPagesConf.Home { get => Home; set => Home = (Config_HomePage?)value ?? new(); } + object? IPagesConf.Device { get => Device; set => Device = value as Config_DevicePage ?? new(); } + object? IPagesConf.Market { get => Market; set => Market = value as Config_MarketPage ?? new(); } + ISettingsPageConf IPagesConf.Settings { get => Settings; set => Settings = (Config_SettingsPage?)value ?? new(); } +} \ No newline at end of file diff --git a/KitX Clients/KitX Core/KitX.Core/Configuration/Config_SettingsPage.cs b/KitX Clients/KitX Core/KitX.Core/Configuration/Config_SettingsPage.cs new file mode 100644 index 00000000..787faca8 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Configuration/Config_SettingsPage.cs @@ -0,0 +1,33 @@ +using KitX.Core.Contract.Configuration; + +namespace KitX.Core.Configuration; + +/// +/// Settings page configuration +/// +public class Config_SettingsPage : ISettingsPageConf +{ + public NavigationViewPaneDisplayMode NavigationViewPaneDisplayMode { get; set; } = NavigationViewPaneDisplayMode.Auto; + + public string SelectedViewName { get; set; } = "View_General"; + + public bool PaletteAreaExpanded { get; set; } = false; + + public bool WebRelatedAreaExpanded { get; set; } = true; + + public bool WebRelatedAreaOfNetworkInterfacesExpanded { get; set; } = false; + + public bool LogRelatedAreaExpanded { get; set; } = true; + + public bool UpdateRelatedAreaExpanded { get; set; } = true; + + public bool AboutAreaExpanded { get; set; } = false; + + public bool AuthorsAreaExpanded { get; set; } = false; + + public bool LinksAreaExpanded { get; set; } = false; + + public bool ThirdPartyLicensesAreaExpanded { get; set; } = false; + + public bool IsNavigationViewPaneOpened { get; set; } = true; +} \ No newline at end of file diff --git a/KitX Clients/KitX Core/KitX.Core/Configuration/Config_Web.cs b/KitX Clients/KitX Core/KitX.Core/Configuration/Config_Web.cs new file mode 100644 index 00000000..c1dd03b4 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Configuration/Config_Web.cs @@ -0,0 +1,52 @@ +using System.Collections.Generic; +using KitX.Core.Contract.Configuration; + +namespace KitX.Core.Configuration; + +/// +/// Web configuration section +/// +public class Config_Web : IWebConf +{ + public double DelayStartSeconds { get; set; } = 0.5; + + public string ApiServer { get; set; } = "api.catrol.cn"; + + public string ApiPath { get; set; } = "/apps/kitx/"; + + public int DevicesViewRefreshDelay { get; set; } = 1000; + + public List? AcceptedNetworkInterfaces { get; set; } = null; + + public int? UserSpecifiedDevicesServerPort { get; set; } = null; + + public int? UserSpecifiedPluginsServerPort { get; set; } = null; + + public int UdpPortSend { get; set; } = 23404; + + public int UdpPortReceive { get; set; } = 24040; + + public int UdpSendFrequency { get; set; } = 1000; + + public string UdpBroadcastAddress { get; set; } = "224.0.0.0"; + + public string IPFilter { get; set; } = "192.168"; + + public int SocketBufferSize { get; set; } = 1024 * 100; + + public int DeviceInfoTTLSeconds { get; set; } = 7; + + public bool DisableRemovingOfflineDeviceCard { get; set; } = false; + + public string UpdateServer { get; set; } = "api.catrol.cn"; + + public string UpdatePath { get; set; } = "/apps/kitx/%platform%/"; + + public string UpdateDownloadPath { get; set; } = "/apps/kitx/update/%platform%/"; + + public string UpdateChannel { get; set; } = "stable"; + + public string UpdateSource { get; set; } = "latest-components.json"; + + public int DebugServicesServerPort { get; set; } = 7777; +} \ No newline at end of file diff --git a/KitX Clients/KitX Core/KitX.Core/Configuration/Config_Windows.cs b/KitX Clients/KitX Core/KitX.Core/Configuration/Config_Windows.cs new file mode 100644 index 00000000..73d1f65f --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Configuration/Config_Windows.cs @@ -0,0 +1,18 @@ +using Common.BasicHelper.Graphics.Screen; +using KitX.Core.Contract.Configuration; + +namespace KitX.Core.Configuration; + +/// +/// Windows configuration section +/// +public class Config_Windows : IWindowsConf +{ + public Config_MainWindow MainWindow { get; set; } = new(); + + public Config_AnnouncementWindow AnnouncementWindow { get; set; } = new(); + + // Explicit interface implementation with setters + IMainWindowConf IWindowsConf.MainWindow { get => MainWindow; set => MainWindow = (Config_MainWindow?)value ?? new(); } + IAnnouncementWindowConf IWindowsConf.AnnouncementWindow { get => AnnouncementWindow; set => AnnouncementWindow = (Config_AnnouncementWindow?)value ?? new(); } +} \ No newline at end of file diff --git a/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs b/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs index a1216390..229d8d5d 100644 --- a/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs +++ b/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs @@ -48,105 +48,58 @@ public static IServiceCollection AddCoreServices(this IServiceCollection service // Configuration Services Log.Information("Registering IConfigService..."); - services.AddSingleton(provider => - { - var service = ConfigManager.Instance; - return service; - }); + services.AddSingleton(ConfigManager.Instance); // Security Services - Log.Information("Registering ISecurityService..."); - services.AddSingleton(provider => - { - var service = SecurityManager.Instance; - return service; - }); + Log.Information("Registering IDeviceKeyService and IEncryptionService..."); + services.AddSingleton(SecurityManager.Instance); + services.AddSingleton(SecurityManager.Instance); // Plugin Services Log.Information("Registering IPluginService..."); - services.AddSingleton(provider => - { - var service = PluginsManager.Instance; - return service; - }); + services.AddSingleton(PluginsManager.Instance); // Workflow Services - Log.Information("Registering IWorkflowService..."); - services.AddSingleton(provider => - { - var service = WorkflowScriptService.Instance; - return service; - }); + Log.Information("Registering workflow services..."); + // Individual service implementations (WorkflowScriptService facade used for backward compatibility) + services.AddSingleton(sp => WorkflowScriptService.BlockScriptServiceInstance); + services.AddSingleton(sp => WorkflowScriptService.PluginServiceInstance); + services.AddSingleton(sp => WorkflowScriptService.ScriptExecutionServiceInstance); + services.AddSingleton(sp => WorkflowScriptService.ManagementServiceInstance); // Activity Services Log.Information("Registering IActivityService..."); - services.AddSingleton(provider => - { - var service = ActivityManager.Instance; - return service; - }); + services.AddSingleton(ActivityManager.Instance); // Statistics Services Log.Information("Registering IStatisticsService..."); - services.AddSingleton(provider => - { - var service = StatisticsManager.Instance; - return service; - }); + services.AddSingleton(StatisticsManager.Instance); // Task Services Log.Information("Registering ITasksService..."); - services.AddSingleton(provider => - { - var service = TasksManager.Instance; - return service; - }); + services.AddSingleton(TasksManager.Instance); // File Watcher Services Log.Information("Registering IFileWatcherService..."); - services.AddSingleton(provider => - { - var service = FileWatcherManager.Instance; - return service; - }); + services.AddSingleton(FileWatcherManager.Instance); // Hotkey Services Log.Information("Registering IKeyHookService..."); - services.AddSingleton(provider => - { - var service = KeyHookManager.Instance; - return service; - }); + services.AddSingleton(KeyHookManager.Instance); // Event Services Log.Information("Registering IEventService..."); - services.AddSingleton(provider => - { - var service = EventService.Instance; - return service; - }); + services.AddSingleton(EventService.Instance); // Phase 5: Device and Network Services Log.Information("Registering IDeviceDiscoveryService..."); - services.AddSingleton(provider => - { - var service = DevicesDiscoveryServer.Instance; - return service; - }); + services.AddSingleton(DevicesDiscoveryServer.Instance); Log.Information("Registering IDeviceServer..."); - services.AddSingleton(provider => - { - var service = DevicesServer.Instance; - return service; - }); + services.AddSingleton(DevicesServer.Instance); Log.Information("Registering IPluginServer..."); - services.AddSingleton(provider => - { - var service = PluginsServer.Instance; - return service; - }); + services.AddSingleton(PluginsServer.Instance); // Phase 5: Device HTTP Client (for cross-device plugin invocation) Log.Information("Registering IDeviceHttpClient..."); @@ -256,19 +209,11 @@ public static IServiceCollection AddCoreServices(this IServiceCollection service // Workflow Storage Service Log.Information("Registering IWorkflowStorageService..."); - services.AddSingleton(provider => - { - var service = WorkflowStorageService.Instance; - return service; - }); + services.AddSingleton(WorkflowStorageService.Instance); // Trigger Manager Log.Information("Registering TriggerManager..."); - services.AddSingleton(provider => - { - var service = TriggerManager.Instance; - return service; - }); + services.AddSingleton(TriggerManager.Instance); Log.Information("AddCoreServices completed."); return services; diff --git a/KitX Clients/KitX Core/KitX.Core/Device/DeviceCase.cs b/KitX Clients/KitX Core/KitX.Core/Device/DeviceCase.cs index a594bc4b..87adaf8c 100644 --- a/KitX Clients/KitX Core/KitX.Core/Device/DeviceCase.cs +++ b/KitX Clients/KitX Core/KitX.Core/Device/DeviceCase.cs @@ -15,7 +15,7 @@ namespace KitX.Core.Device; public class DeviceCase : IDeviceCase { private readonly IConfigService _configService; - private readonly ISecurityService _securityService; + private readonly IDeviceKeyService _securityService; private readonly DevicesServer _devicesServer; /// diff --git a/KitX Clients/KitX Core/KitX.Core/Device/DevicesDiscoveryServer.cs b/KitX Clients/KitX Core/KitX.Core/Device/DevicesDiscoveryServer.cs index b5963384..b6b8fd5f 100644 --- a/KitX Clients/KitX Core/KitX.Core/Device/DevicesDiscoveryServer.cs +++ b/KitX Clients/KitX Core/KitX.Core/Device/DevicesDiscoveryServer.cs @@ -246,7 +246,6 @@ private void UpdateDefaultDeviceInfo() .ResetIPv6(NetworkHelper.GetInterNetworkIPv6()); DefaultDeviceInfo.PluginsServerPort = ConstantTable.PluginsServerPort; DefaultDeviceInfo.PluginsCount = PluginsServer.Instance?.Connections?.Count ?? 0; - DefaultDeviceInfo.IsMainDevice = ConstantTable.IsMainMachine; DefaultDeviceInfo.DevicesServerPort = ConstantTable.DevicesServerPort; DefaultDeviceInfo.DevicesServerBuildTime = ConstantTable.ServerBuildTime; diff --git a/KitX Clients/KitX Core/KitX.Core/Device/DevicesOrganizer.cs b/KitX Clients/KitX Core/KitX.Core/Device/DevicesOrganizer.cs index c74bcb00..c317df02 100644 --- a/KitX Clients/KitX Core/KitX.Core/Device/DevicesOrganizer.cs +++ b/KitX Clients/KitX Core/KitX.Core/Device/DevicesOrganizer.cs @@ -321,24 +321,3 @@ private void WatchingOver(bool foundMainDevice, string serverAddress, int server } } } - -/// -/// Devices organizer interface -/// -public interface IDevicesOrganizer -{ - /// - /// Updates the source and adds device cards - /// - void UpdateSourceAndAddCards(DeviceInfo deviceInfo); - - /// - /// Event raised when a device is discovered - /// - event EventHandler? DeviceDiscovered; - - /// - /// Event raised when a device goes offline - /// - event EventHandler? DeviceOffline; -} diff --git a/KitX Clients/KitX Core/KitX.Core/Device/DevicesServer.cs b/KitX Clients/KitX Core/KitX.Core/Device/DevicesServer.cs index 44305e17..db06f8cd 100644 --- a/KitX Clients/KitX Core/KitX.Core/Device/DevicesServer.cs +++ b/KitX Clients/KitX Core/KitX.Core/Device/DevicesServer.cs @@ -10,6 +10,7 @@ using System.Threading.Tasks; using KitX.Core.Contract.Device; using KitX.Core.Contract.Plugin; +using KitX.Core.Contract.Plugin.Events; using KitX.Core.Event; using KitX.Core.Security; using KitX.Shared.CSharp.Device; @@ -634,166 +635,6 @@ private async System.Threading.Tasks.Task HandleConnectAsync(HttpContext context } } - /// - /// Handles legacy ExchangeKey request (旧架构兼容) - /// - private async System.Threading.Tasks.Task HandleExchangeKeyLegacyAsync(HttpContext context) - { - try - { - if (_isExchangingDeviceKey) - { - context.Response.StatusCode = 400; - await context.Response.WriteAsync("Remote device is exchanging device key."); - return; - } - - var securityService = SecurityManager.Instance; - if (securityService.LocalDeviceKey == null) - { - context.Response.StatusCode = 400; - await context.Response.WriteAsync("Remote device didn't set up device key."); - return; - } - - // Read query parameters (legacy format) - var verifyCodeSHA1 = context.Request.Query["verifyCodeSHA1"].ToString(); - var address = context.Request.Query["address"].ToString(); - - // Read body (device key as plain string) - using var reader = new StreamReader(context.Request.Body); - var deviceKey = await reader.ReadToEndAsync(); - - // Generate verification code - _exchangeDeviceKeyCode = Guid.NewGuid().ToString("N")[..8]; - _isExchangingDeviceKey = true; - - // Auto-accept for now (legacy would show UI) - var deviceKeyDecrypted = securityService.AesDecrypt(deviceKey, _exchangeDeviceKeyCode); - var deviceKeyInstance = JsonSerializer.Deserialize(deviceKeyDecrypted); - - if (deviceKeyInstance == null) - { - _isExchangingDeviceKey = false; - _exchangeDeviceKeyCode = null; - context.Response.StatusCode = 400; - await context.Response.WriteAsync("Failed to decrypt device key"); - return; - } - - // Add device key - securityService.AddDeviceKey( - deviceKeyInstance.Device.MacAddress, - deviceKeyInstance.Device.DeviceName, - deviceKeyInstance.RsaPublicKeyPem ?? "" - ); - - // Send back local key - var currentKey = securityService.GetPrivateDeviceKey(); - if (currentKey == null) - { - _isExchangingDeviceKey = false; - _exchangeDeviceKeyCode = null; - context.Response.StatusCode = 500; - await context.Response.WriteAsync("Failed to get local key"); - return; - } - - var currentKeyJson = JsonSerializer.Serialize(currentKey); - var currentKeyEncrypted = securityService.AesEncrypt(currentKeyJson, _exchangeDeviceKeyCode); - - _isExchangingDeviceKey = false; - _exchangeDeviceKeyCode = null; - - // Return as plain string (legacy format) - await context.Response.WriteAsync(currentKeyEncrypted); - } - catch (Exception ex) - { - Log.Error(ex, "Error in HandleExchangeKeyLegacyAsync"); - _isExchangingDeviceKey = false; - _exchangeDeviceKeyCode = null; - context.Response.StatusCode = 500; - await context.Response.WriteAsync($"Error: {ex.Message}"); - } - } - - /// - /// Handles legacy Connect request (旧架构兼容) - /// - private async System.Threading.Tasks.Task HandleConnectLegacyAsync(HttpContext context) - { - try - { - var deviceBase64 = context.Request.Query["deviceBase64"].ToString(); - if (string.IsNullOrEmpty(deviceBase64)) - { - context.Response.StatusCode = 400; - await context.Response.WriteAsync($"Missing deviceBase64 parameter"); - return; - } - - // Read body (encrypted device name as plain string) - using var reader = new StreamReader(context.Request.Body); - var deviceNameEncrypted = await reader.ReadToEndAsync(); - - // Decode device locator - var deviceBytes = Convert.FromBase64String(deviceBase64); - var deviceJson = Encoding.UTF8.GetString(deviceBytes); - var device = JsonSerializer.Deserialize(deviceJson); - - if (device == null) - { - context.Response.StatusCode = 400; - await context.Response.WriteAsync($"Invalid deviceBase64 parameter"); - return; - } - - // Search for device key - var securityService = SecurityManager.Instance; - var key = securityService.SearchDeviceKey(device); - - if (key == null) - { - context.Response.StatusCode = 401; - await context.Response.WriteAsync("You are not authorized by remote device."); - return; - } - - // Decrypt and verify device name - var deviceNameDecrypted = securityService.RsaDecryptString(key, deviceNameEncrypted); - - if (deviceNameDecrypted == null) - { - context.Response.StatusCode = 500; - await context.Response.WriteAsync("Remote crashed when decrypting device name."); - return; - } - - if (!device.DeviceName.Equals(deviceNameDecrypted)) - { - context.Response.StatusCode = 400; - await context.Response.WriteAsync("You provided incorrect encrypted device name."); - return; - } - - // Sign in device - var token = SignInDevice(device); - - // Encrypt token (legacy uses EncryptString) - var encryptedToken = securityService.EncryptStringAsync(token, "").Result; - - context.Response.ContentType = "application/json"; - await context.Response.WriteAsync(encryptedToken); - } - catch (Exception ex) - { - Log.Error(ex, "Error in HandleConnectLegacyAsync"); - context.Response.StatusCode = 500; - await context.Response.WriteAsync($"Error: {ex.Message}"); - } - } - /// /// Handles Plugin/Invoke request — routes plugin command to local PluginsServer connection. /// Protocol compatible with legacy PluginController.Invoke. @@ -1020,24 +861,3 @@ private string DecryptContent(Request request, string token) return content; } } - -/// -/// Exchange key request model -/// -public class ExchangeKeyRequest -{ - /// - /// AES encrypted device key - /// - public string? DeviceKey { get; set; } - - /// - /// Address of requesting device - /// - public string? Address { get; set; } - - /// - /// SHA1 of verification code - /// - public string? VerifyCodeSHA1 { get; set; } -} diff --git a/KitX Clients/KitX Core/KitX.Core/Device/Events/PluginEventArgs.cs b/KitX Clients/KitX Core/KitX.Core/Device/Events/PluginEventArgs.cs new file mode 100644 index 00000000..f1a4aabc --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Device/Events/PluginEventArgs.cs @@ -0,0 +1,42 @@ +using System; +using KitX.Shared.CSharp.WebCommand; + +namespace KitX.Core.Device.Events; + +/// +/// Plugin connected event arguments +/// +public class PluginConnectedEventArgs : EventArgs +{ + /// + /// Gets or sets the connection ID + /// + public string? ConnectionId { get; set; } +} + +/// +/// Plugin disconnected event arguments +/// +public class PluginDisconnectedEventArgs : EventArgs +{ + /// + /// Gets or sets the connection ID + /// + public string? ConnectionId { get; set; } +} + +/// +/// Plugin message received event arguments +/// +public class PluginMessageReceivedEventArgs : EventArgs +{ + /// + /// Gets or sets the connection ID + /// + public string? ConnectionId { get; set; } + + /// + /// Gets or sets the message + /// + public string? Message { get; set; } +} \ No newline at end of file diff --git a/KitX Clients/KitX Core/KitX.Core/Device/ExchangeKeyRequest.cs b/KitX Clients/KitX Core/KitX.Core/Device/ExchangeKeyRequest.cs new file mode 100644 index 00000000..1a970c8e --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Device/ExchangeKeyRequest.cs @@ -0,0 +1,22 @@ +namespace KitX.Core.Device; + +/// +/// Exchange key request model +/// +public class ExchangeKeyRequest +{ + /// + /// AES encrypted device key + /// + public string? DeviceKey { get; set; } + + /// + /// Address of requesting device + /// + public string? Address { get; set; } + + /// + /// SHA1 of verification code + /// + public string? VerifyCodeSHA1 { get; set; } +} \ No newline at end of file diff --git a/KitX Clients/KitX Core/KitX.Core/Device/IPluginConnection.cs b/KitX Clients/KitX Core/KitX.Core/Device/IPluginConnection.cs new file mode 100644 index 00000000..d1a25280 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Device/IPluginConnection.cs @@ -0,0 +1,52 @@ +using System; +using KitX.Shared.CSharp.Plugin; +using CTask = System.Threading.Tasks.Task; + +namespace KitX.Core.Device; + +/// +/// Plugin connection interface +/// +public interface IPluginConnection +{ + /// + /// Gets the connection ID + /// + string? ConnectionId { get; } + + /// + /// Gets or sets the plugin info + /// + PluginInfo? PluginInfo { get; set; } + + /// + /// Gets the connection status + /// + ServerStatus Status { get; } + + /// + /// Event raised when a message is received + /// + event EventHandler? MessageReceived; + + /// + /// Event raised when connection is closed + /// + event EventHandler? Closed; + + /// + /// Initializes the connection + /// + void Initialize(); + + /// + /// Sends a message + /// + /// The message to send + void Send(string message); + + /// + /// Closes the connection + /// + CTask CloseAsync(); +} \ No newline at end of file diff --git a/KitX Clients/KitX Core/KitX.Core/Device/PluginConnection.cs b/KitX Clients/KitX Core/KitX.Core/Device/PluginConnection.cs new file mode 100644 index 00000000..6cfe9d28 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Device/PluginConnection.cs @@ -0,0 +1,159 @@ +using System; +using System.Text.Json; +using Fleck; +using KitX.Core.Contract.Plugin; +using KitX.Core.Contract.Plugin.Events; +using KitX.Shared.CSharp.Plugin; +using KitX.Shared.CSharp.WebCommand; +using Serilog; + +namespace KitX.Core.Device; + +/// +/// Plugin connection implementation +/// +public class PluginConnection : IPluginConnection, IPluginConnector +{ + private readonly IWebSocketConnection _connection; + private ServerStatus _status = ServerStatus.Pending; + + /// + /// Gets the connection ID + /// + public string? ConnectionId { get; private set; } + + /// + /// IPluginConnector.ConnectionId — non-nullable explicit implementation + /// + string IPluginConnector.ConnectionId => ConnectionId!; + + /// + /// Gets or sets the plugin info + /// + public PluginInfo? PluginInfo { get; set; } + + /// + /// Gets the connection status + /// + public ServerStatus Status => _status; + + /// + /// Event raised when a message is received + /// + public event EventHandler? MessageReceived; + + /// + /// Event raised when connection is closed + /// + public event EventHandler? Closed; + + /// + /// Event raised when a plugin response is received (IPluginConnector implementation) + /// + public event EventHandler? PluginResponse; + + /// + /// Event raised when plugin reports status (IPluginConnector implementation) + /// +#pragma warning disable CS0067 + public event EventHandler? StatusReport; +#pragma warning restore CS0067 + + /// + /// Constructor + /// + /// The WebSocket connection + /// The connection ID + public PluginConnection(IWebSocketConnection connection, string connectionId) + { + _connection = connection; + ConnectionId = connectionId; + } + + /// + /// Initializes the connection + /// + public void Initialize() + { + _connection.OnOpen = () => + { + _status = ServerStatus.Running; + }; + + _connection.OnMessage = message => + { + // Handle plugin response messages + try + { + var kwc = JsonSerializer.Deserialize(message, PluginsServer.SerializerOptions); + if (kwc?.Content is not null) + { + var command = JsonSerializer.Deserialize(kwc.Content, PluginsServer.SerializerOptions); + if (command.Tags != null && + command.Tags.TryGetValue("RequestId", out var requestId)) + { + // This is a plugin response - trigger PluginResponse event + PluginResponse?.Invoke(this, new PluginResponseEventArgs + { + RequestId = requestId, + Content = kwc.Content + }); + return; + } + } + } + catch (Exception ex) + { + Log.Warning(ex, "Error parsing plugin response message"); + } + + // Forward to MessageReceived for other handlers + MessageReceived?.Invoke(this, message); + }; + + _connection.OnClose = () => + { + _status = ServerStatus.Pending; + Closed?.Invoke(this, EventArgs.Empty); + }; + + _connection.OnError = ex => + { + _status = ServerStatus.Errored; + Serilog.Log.Error(ex, $"PluginConnection error for {ConnectionId}, triggering Closed event"); + + // Also trigger Closed event when error occurs (e.g., remote host disconnected abruptly) + Closed?.Invoke(this, EventArgs.Empty); + }; + } + + /// + /// Sends a message + /// + /// The message to send + public void Send(string message) + { + _connection.Send(message); + } + + /// + /// Sends a request to the plugin (IPluginConnector implementation) + /// + /// The request to send + public void Request(object request) + { + var json = JsonSerializer.Serialize(request, PluginsServer.SerializerOptions); + _connection.Send(json); + } + + /// + /// Closes the connection + /// + public async System.Threading.Tasks.Task CloseAsync() + { + await System.Threading.Tasks.Task.Run(() => + { + _connection.Close(); + }); + } +} \ No newline at end of file diff --git a/KitX Clients/KitX Core/KitX.Core/Device/PluginsServer.cs b/KitX Clients/KitX Core/KitX.Core/Device/PluginsServer.cs index fb27c191..bfc3297b 100644 --- a/KitX Clients/KitX Core/KitX.Core/Device/PluginsServer.cs +++ b/KitX Clients/KitX Core/KitX.Core/Device/PluginsServer.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Text.Json; @@ -7,11 +7,13 @@ using Fleck; using KitX.Core; using KitX.Core.Contract.Plugin; +using KitX.Core.Contract.Plugin.Events; using KitX.Core.Event; using KitX.Shared.CSharp.Plugin; using KitX.Shared.CSharp.WebCommand; using Serilog; using CTask = System.Threading.Tasks.Task; +using KitX.Core.Device.Events; namespace KitX.Core.Device; @@ -397,238 +399,4 @@ await CTask.Run(() => /// private static Regex RegexToVerifyConnectionId() => new(@"^[{]?[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}[}]?$"); -} - -/// -/// Plugin connection interface -/// -public interface IPluginConnection -{ - /// - /// Gets the connection ID - /// - string? ConnectionId { get; } - - /// - /// Gets or sets the plugin info - /// - PluginInfo? PluginInfo { get; set; } - - /// - /// Gets the connection status - /// - ServerStatus Status { get; } - - /// - /// Event raised when a message is received - /// - event EventHandler? MessageReceived; - - /// - /// Event raised when connection is closed - /// - event EventHandler? Closed; - - /// - /// Initializes the connection - /// - void Initialize(); - - /// - /// Sends a message - /// - /// The message to send - void Send(string message); - - /// - /// Closes the connection - /// - CTask CloseAsync(); -} - -/// -/// Plugin connection implementation -/// -public class PluginConnection : IPluginConnection, IPluginConnector -{ - private readonly IWebSocketConnection _connection; - private ServerStatus _status = ServerStatus.Pending; - - /// - /// Gets the connection ID - /// - public string? ConnectionId { get; private set; } - - /// - /// IPluginConnector.ConnectionId — non-nullable explicit implementation - /// - string IPluginConnector.ConnectionId => ConnectionId!; - - /// - /// Gets or sets the plugin info - /// - public PluginInfo? PluginInfo { get; set; } - - /// - /// Gets the connection status - /// - public ServerStatus Status => _status; - - /// - /// Event raised when a message is received - /// - public event EventHandler? MessageReceived; - - /// - /// Event raised when connection is closed - /// - public event EventHandler? Closed; - - /// - /// Event raised when a plugin response is received (IPluginConnector implementation) - /// - public event EventHandler? PluginResponse; - - /// - /// Event raised when plugin reports status (IPluginConnector implementation) - /// -#pragma warning disable CS0067 - public event EventHandler? StatusReport; -#pragma warning restore CS0067 - - /// - /// Constructor - /// - /// The WebSocket connection - /// The connection ID - public PluginConnection(IWebSocketConnection connection, string connectionId) - { - _connection = connection; - ConnectionId = connectionId; - } - - /// - /// Initializes the connection - /// - public void Initialize() - { - _connection.OnOpen = () => - { - _status = ServerStatus.Running; - }; - - _connection.OnMessage = message => - { - // Handle plugin response messages - try - { - var kwc = JsonSerializer.Deserialize(message, PluginsServer.SerializerOptions); - if (kwc?.Content is not null) - { - var command = JsonSerializer.Deserialize(kwc.Content, PluginsServer.SerializerOptions); - if (command.Tags != null && - command.Tags.TryGetValue("RequestId", out var requestId)) - { - // This is a plugin response - trigger PluginResponse event - PluginResponse?.Invoke(this, new PluginResponseEventArgs - { - RequestId = requestId, - Content = kwc.Content - }); - return; - } - } - } - catch (Exception ex) - { - Log.Warning(ex, "Error parsing plugin response message"); - } - - // Forward to MessageReceived for other handlers - MessageReceived?.Invoke(this, message); - }; - - _connection.OnClose = () => - { - _status = ServerStatus.Pending; - Closed?.Invoke(this, EventArgs.Empty); - }; - - _connection.OnError = ex => - { - _status = ServerStatus.Errored; - Serilog.Log.Error(ex, $"PluginConnection error for {ConnectionId}, triggering Closed event"); - - // Also trigger Closed event when error occurs (e.g., remote host disconnected abruptly) - Closed?.Invoke(this, EventArgs.Empty); - }; - } - - /// - /// Sends a message - /// - /// The message to send - public void Send(string message) - { - _connection.Send(message); - } - - /// - /// Sends a request to the plugin (IPluginConnector implementation) - /// - /// The request to send - public void Request(object request) - { - var json = JsonSerializer.Serialize(request, PluginsServer.SerializerOptions); - _connection.Send(json); - } - - /// - /// Closes the connection - /// - public async CTask CloseAsync() - { - await CTask.Run(() => - { - _connection.Close(); - }); - } -} - -/// -/// Plugin connected event arguments -/// -public class PluginConnectedEventArgs : EventArgs -{ - /// - /// Gets or sets the connection ID - /// - public string? ConnectionId { get; set; } -} - -/// -/// Plugin disconnected event arguments -/// -public class PluginDisconnectedEventArgs : EventArgs -{ - /// - /// Gets or sets the connection ID - /// - public string? ConnectionId { get; set; } -} - -/// -/// Plugin message received event arguments -/// -public class PluginMessageReceivedEventArgs : EventArgs -{ - /// - /// Gets or sets the connection ID - /// - public string? ConnectionId { get; set; } - - /// - /// Gets or sets the message - /// - public string? Message { get; set; } -} +} \ No newline at end of file diff --git a/KitX Clients/KitX Core/KitX.Core/Plugin/PluginConnector.cs b/KitX Clients/KitX Core/KitX.Core/Plugin/PluginConnector.cs deleted file mode 100644 index b0b91f68..00000000 --- a/KitX Clients/KitX Core/KitX.Core/Plugin/PluginConnector.cs +++ /dev/null @@ -1,308 +0,0 @@ -using System; -using System.Text; -using System.Text.Json; -using System.Threading.Tasks; -using Fleck; -using KitX.Core.Device; -using KitX.Shared.CSharp.Plugin; -using KitX.Shared.CSharp.WebCommand; -using KitX.Shared.CSharp.WebCommand.Details; -using KitX.Shared.CSharp.WebCommand.Infos; -using Serilog; -using CTask = System.Threading.Tasks.Task; - -namespace KitX.Core.Plugin; - -/// -/// Plugin connector for managing plugin WebSocket connections -/// Phase 5: Decoupled from UI ViewInstances, uses events instead -/// -public class PluginConnector -{ - private readonly IWebSocketConnection? _connection; - private readonly IWebSocketConnectionInfo? _connectionInfo; - private string? _path; - private bool _initialized = false; - private PluginInfo? _pluginInfo; - private ServerStatus _connectorStatus = ServerStatus.Pending; - private readonly JsonSerializerOptions _serializerOptions = new() - { - WriteIndented = true, - IncludeFields = true, - PropertyNameCaseInsensitive = true, - }; - - /// - /// Plugin status updated event - /// - public event Action? PluginStatusUpdated; - - /// - /// Plugin response event - when receiving plugin function call responses - /// - public static event Action? OnPluginResponse; - - /// - /// Event raised when a plugin is registered - /// UI layer should subscribe to this to update ViewInstances.PluginInfos - /// - public event EventHandler? PluginRegistered; - - /// - /// Event raised when a plugin is unregistered/disconnected - /// UI layer should subscribe to this to update ViewInstances.PluginInfos - /// - public event EventHandler? PluginUnregistered; - - public PluginConnector() { } - - public PluginConnector(IWebSocketConnection socket) - { - _connection = socket; - _connectionInfo = socket.ConnectionInfo; - } - - /// - /// Gets or sets the plugin path - /// - public string? Path - { - get => _path; - set - { - _path = value; - PluginStatusUpdated?.Invoke(); - } - } - - /// - /// Gets the connection ID - /// - public string? ConnectionId => Path; - - /// - /// Gets or sets the plugin info - /// - public PluginInfo? PluginInfo - { - get => _pluginInfo; - set - { - _pluginInfo = value; - PluginStatusUpdated?.Invoke(); - } - } - - /// - /// Gets a value indicating whether plugin info is available - /// - public bool PluginInfoAvailable => PluginInfo is null; - - /// - /// Gets or sets the connector status - /// - public ServerStatus ConnectorStatus - { - get => _connectorStatus; - set - { - _connectorStatus = value; - PluginStatusUpdated?.Invoke(); - } - } - - /// - /// Initializes the connector - /// - public PluginConnector Initialize() - { - _initialized = true; - Path = _connectionInfo!.Path.Trim('/'); - return this; - } - - /// - /// Runs the connector - /// - public PluginConnector Run() - { - if (_initialized == false) - Initialize(); - - const string location = $"{nameof(PluginConnector)}.{nameof(Run)}"; - - _connection!.OnOpen = () => { }; - - _connection.OnClose = () => - { - try - { - if (PluginInfo is not null) - { - // Trigger event instead of directly removing from ViewInstances - PluginUnregistered?.Invoke(this, new PluginUnregisteredEventArgs - { - PluginInfo = PluginInfo - }); - } - } - catch (Exception e) - { - Log.Warning(e, $"In {location}: {e.Message}"); - } - - // TODO: Remove from PluginsServer.PluginConnectors - }; - - _connection.OnMessage = message => - { - var kwc = JsonSerializer.Deserialize(message, _serializerOptions); - - if (kwc is null) - return; - - var command = JsonSerializer.Deserialize(kwc.Content, _serializerOptions); - - // Command is a value type, so it can't be null, but we check deserialization success - if (command.Tags != null && - command.Tags.TryGetValue("RequestId", out var requestId)) - { - // Trigger plugin response event - OnPluginResponse?.Invoke(requestId, kwc.Content); - return; - } - - HandleCommand(command, kwc); - }; - - _connection.OnError = ex => - { - try - { - if (PluginInfo is not null) - { - // Trigger event instead of directly removing from ViewInstances - PluginUnregistered?.Invoke(this, new PluginUnregisteredEventArgs - { - PluginInfo = PluginInfo - }); - } - } - catch (Exception e) - { - Log.Warning(e, $"In {location}: {e.Message}"); - } - - // TODO: Remove from PluginsServer.PluginConnectors - - Log.Error(ex, $"In {location}: {ex.Message}"); - }; - - return this; - } - - /// - /// Handles incoming commands from the plugin - /// - private void HandleCommand(Command command, Request request) - { - const string location = $"{nameof(PluginConnector)}.{nameof(HandleCommand)}"; - - switch (command.Request) - { - case CommandRequestInfo.RegisterPlugin: - var body = Encoding.UTF8.GetString(command.Body.AsSpan(0, command.BodyLength).ToArray()); - PluginInfo = JsonSerializer.Deserialize(body, _serializerOptions); - - ArgumentNullException.ThrowIfNull(PluginInfo, nameof(PluginInfo)); - ArgumentNullException.ThrowIfNull(PluginInfo.Tags, nameof(PluginInfo.Tags)); - - PluginInfo.Tags.Add(nameof(ConnectionId), ConnectionId ?? string.Empty); - PluginInfo.Tags.Add("JoinTime", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss(FF)")); - - // Trigger event instead of directly adding to ViewInstances - PluginRegistered?.Invoke(this, new PluginRegisteredEventArgs - { - PluginInfo = PluginInfo - }); - - Log.Information($"In {location}: New plugin registered with {body.Replace("\r", "").Replace("\n", "")}"); - break; - - case CommandRequestInfo.RequestWorkingDetail: - SendWorkingDetail(); - break; - - case CommandRequestInfo.ReportStatus: - // TODO: Handle status report - break; - - case CommandRequestInfo.RequestCommand: - // TODO: Handle command request - break; - } - } - - /// - /// Sends a message - /// - private void SendMessage(T content) => _connection!.Send(JsonSerializer.Serialize(content, _serializerOptions)); - - /// - /// Sends working detail - /// - private void SendWorkingDetail() - { - if (_path is null or { Length: 0 }) - { - SendMessage(new PluginWorkingDetail - { - PluginDataDirectory = null, - PluginSaveDirectory = null - }); - } - } - - /// - /// Sends a request to the plugin - /// - public async void Request(Request request) - { - await _connection!.Send(JsonSerializer.Serialize(request, _serializerOptions)); - } - - /// - /// Closes the connector - /// - public async Task CloseAsync() - { - await CTask.Run(() => - { - _connection!.Close(); - }); - - return this; - } -} - -/// -/// Plugin registered event arguments -/// -public class PluginRegisteredEventArgs : EventArgs -{ - /// - /// Gets or sets the plugin info - /// - public PluginInfo? PluginInfo { get; set; } -} - -/// -/// Plugin unregistered event arguments -/// -public class PluginUnregisteredEventArgs : EventArgs -{ - /// - /// Gets or sets the plugin info - /// - public PluginInfo? PluginInfo { get; set; } -} diff --git a/KitX Clients/KitX Core/KitX.Core/Plugin/PluginInstallation.cs b/KitX Clients/KitX Core/KitX.Core/Plugin/PluginInstallation.cs new file mode 100644 index 00000000..f90031d1 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Plugin/PluginInstallation.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using KitX.Core.Contract.Configuration; +using KitX.Shared.CSharp.Device; +using KitX.Shared.CSharp.Loader; +using KitX.Shared.CSharp.Plugin; + +namespace KitX.Core.Plugin; + +/// +/// Plugin installation implementation +/// +public class PluginInstallation : IPluginInstallation +{ + /// + /// Gets the unique identifier for this plugin installation + /// + public Guid Id { get; set; } + + /// + /// Gets the installation path + /// + public string? InstallPath { get; set; } + + /// + /// Gets or sets the plugin information + /// + public PluginInfo? PluginInfo { get; set; } + + /// + /// Gets or sets the loader information + /// + public LoaderInfo? LoaderInfo { get; set; } + + private List _installedDevices = new(); + + /// + /// Gets or sets the list of installed devices + /// + public IList InstalledDevices + { + get => _installedDevices; + set => _installedDevices = new List(value); + } + + /// + /// Gets or sets a value indicating whether the plugin is running + /// + public bool IsRunning { get; set; } +} \ No newline at end of file diff --git a/KitX Clients/KitX Core/KitX.Core/Plugin/PluginsManager.cs b/KitX Clients/KitX Core/KitX.Core/Plugin/PluginsManager.cs index d6e1578b..a24ec70b 100644 --- a/KitX Clients/KitX Core/KitX.Core/Plugin/PluginsManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/Plugin/PluginsManager.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using KitX.Core.Contract.Configuration; using KitX.Core.Contract.Plugin; +using KitX.Core.Contract.Plugin.Events; using KitX.Shared.CSharp.Device; using KitX.Shared.CSharp.Loader; using KitX.Shared.CSharp.Plugin; @@ -617,28 +618,3 @@ public async Task StopPluginAsync(Guid pluginId) } } } - -/// -/// Plugin installation implementation -/// -public class PluginInstallation : IPluginInstallation -{ - /// - /// Gets the unique identifier for this plugin installation - /// - public Guid Id { get; set; } - - public string? InstallPath { get; set; } - public PluginInfo? PluginInfo { get; set; } - public LoaderInfo? LoaderInfo { get; set; } - - private List _installedDevices = new(); - - public IList InstalledDevices - { - get => _installedDevices; - set => _installedDevices = new List(value); - } - - public bool IsRunning { get; set; } -} diff --git a/KitX Clients/KitX Core/KitX.Core/Security/SecurityManager.cs b/KitX Clients/KitX Core/KitX.Core/Security/SecurityManager.cs index 84b1d1e5..fbd3eb97 100644 --- a/KitX Clients/KitX Core/KitX.Core/Security/SecurityManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/Security/SecurityManager.cs @@ -15,7 +15,7 @@ namespace KitX.Core.Security; /// /// Security manager for encryption and device key management /// -public class SecurityManager : ISecurityService +public class SecurityManager : IDeviceKeyService, IEncryptionService { private static SecurityManager? _instance; @@ -350,16 +350,6 @@ public async Task DecryptStringAsync(string encryptedContent, string sou } } - /// - /// Computes the SHA1 hash of a string - /// - /// The content - /// The hash string - public string ComputeHash(string content) - { - return GetSHA1(content); - } - /// /// Computes SHA1 hash of a string /// diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScriptServiceImpl.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScriptServiceImpl.cs new file mode 100644 index 00000000..61a0fb01 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScriptServiceImpl.cs @@ -0,0 +1,215 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using KitX.Core.Contract.Workflow; +using KitX.Core.Device; +using KitX.Core.Workflow.BlockScripting; +using KitX.Shared.CSharp.Plugin; +using Serilog; + +namespace KitX.Core.Workflow; + +/// +/// BlockScript parsing and execution service. +/// Implements IBlockScriptService. +/// +internal class BlockScriptServiceImpl : IBlockScriptService +{ + private readonly WorkflowRuntimeState _state; + + /// + /// Initializes a new instance of BlockScriptServiceImpl. + /// + /// Shared runtime state. + internal BlockScriptServiceImpl(WorkflowRuntimeState state) + { + _state = state; + } + + /// + /// Gets the BlockScript parser, creating it if necessary. + /// + private BlockScriptParser BlockScriptParser => + _state.BlockScriptParser ??= new BlockScriptParser( + BuiltinFunctionRegistry.Discover(typeof(BuiltinFunctionRegistry).Assembly)); + + /// + /// Gets the BlockScript executor, creating it if necessary. + /// The executor is initialized with the plugin manager if the parser is initialized. + /// + private BlockScriptExecutor BlockScriptExecutor + { + get + { + if (_state.BlockScriptExecutor == null) + { + _state.BlockScriptExecutor = new BlockScriptExecutor(); + if (_state.IsParserInitialized) + { + var realPluginManager = new RealPluginManager(PluginsServer.Instance); + _state.BlockScriptExecutor.SetPluginManager(realPluginManager); + } + } + return _state.BlockScriptExecutor; + } + } + + /// + public BlockScriptParseResult ParseBlockScript(string sourceCode) + { + return BlockScriptParser.Parse(sourceCode); + } + + /// + public Task ParseBlockScriptAsync(string sourceCode) + { + return BlockScriptParser.ParseAsync(sourceCode); + } + + /// + public BlockScriptValidationResult ValidateBlockScript(string sourceCode) + { + return BlockScriptParser.Validate(sourceCode); + } + + /// + public List ParseConstantsFromBlockScript(string sourceCode) + { + var result = new List(); + + if (string.IsNullOrWhiteSpace(sourceCode)) + return result; + + try + { + var parseResult = BlockScriptParser.Parse(sourceCode); + + if (!parseResult.IsSuccess || parseResult.Script?.ConstBlock == null) + return result; + + foreach (var variable in parseResult.Script.ConstBlock.Variables) + { + if (variable.DefaultValue != null) + { + result.Add(new VariableConstant + { + Name = variable.Name, + DefaultValue = variable.DefaultValue, + UserValue = variable.DefaultValue, + Type = variable.Type + }); + } + } + } + catch (Exception ex) + { + Log.Warning(ex, "[BlockScriptServiceImpl] Error parsing constants from BlockScript"); + } + + return result; + } + + /// + public Task ExecuteBlockScriptAsync( + BlockScript script, + Dictionary? parameters = null, + CancellationToken cancellationToken = default) + { + return BlockScriptExecutor.ExecuteAsync(script, parameters, cancellationToken); + } + + /// + public Task ExecuteBlockScriptAsync( + string sourceCode, + Dictionary? parameters = null, + CancellationToken cancellationToken = default) + { + return ExecuteBlockScriptCoreAsync(sourceCode, null, parameters, cancellationToken); + } + + /// + public Task ExecuteBlockScriptAsync( + string sourceCode, + List helperFunctions, + CancellationToken cancellationToken = default) + { + return ExecuteBlockScriptCoreAsync(sourceCode, helperFunctions, null, cancellationToken); + } + + /// + public Task ExecuteBlockScriptAsync( + string sourceCode, + List helperFunctions, + Dictionary? constantOverrides, + CancellationToken cancellationToken = default) + { + return ExecuteBlockScriptCoreAsync(sourceCode, helperFunctions, constantOverrides, cancellationToken); + } + + /// + /// Core block script execution logic: parse → validate → execute. + /// + private async Task ExecuteBlockScriptCoreAsync( + string sourceCode, + List? helperFunctions, + Dictionary? constantOverrides, + CancellationToken cancellationToken) + { + // 1. Parse + var parseResult = BlockScriptParser.Parse(sourceCode); + + if (!parseResult.IsSuccess || parseResult.Script == null) + { + return new BlockScriptExecutionResult + { + IsSuccess = false, + ErrorMessage = parseResult.ErrorMessage ?? "Failed to parse block script" + }; + } + + // 2. Validate + var validationResult = BlockScriptExecutor.Validate(parseResult.Script); + if (!validationResult.IsValid) + { + return new BlockScriptExecutionResult + { + IsSuccess = false, + ErrorMessage = string.Join("; ", validationResult.Errors) + }; + } + + // 3. Attach helper functions if provided + if (helperFunctions != null) + parseResult.Script.HelperFunctions = helperFunctions; + + // 3.5. Apply constant overrides from user edits + if (constantOverrides != null && parseResult.Script.ConstBlock != null) + { + foreach (var variable in parseResult.Script.ConstBlock.Variables) + { + if (constantOverrides.TryGetValue(variable.Name, out var userValue)) + { + variable.DefaultValue = userValue; + } + } + } + + // 4. Execute + return await BlockScriptExecutor.ExecuteAsync(parseResult.Script, constantOverrides, cancellationToken); + } + + /// + public Task CompileAndPersistAsync(BlockScript script, string workflowId) + { + BlockScriptExecutor.SetWorkflowId(workflowId); + var compiled = BlockScriptExecutor.CompileForPersistence(script, workflowId); + return Task.FromResult(compiled); + } + + /// + public int PreloadCompiledScripts(string workflowId) + { + return BlockScriptExecutor.PreloadFromDisk(workflowId); + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/CompiledScriptEntry.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/CompiledScriptEntry.cs new file mode 100644 index 00000000..395f7462 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/CompiledScriptEntry.cs @@ -0,0 +1,65 @@ +using System; +using System.Reflection; +using System.Runtime.Loader; +using KitX.Core.Contract.Workflow; +using Serilog; + +namespace KitX.Core.Workflow.BlockScripting; + +/// +/// Wraps a compiled script instance with its +/// to enable unloading after the script execution context is no longer needed. +/// +internal class CompiledScriptEntry +{ + /// + /// The compiled script instance. + /// + public ICompiledBlockScript Instance { get; } + + private readonly CollectibleAssemblyLoadContext _alc; + + /// + /// Initializes a new compiled script entry. + /// + /// The compiled script instance. + /// The collectible assembly load context. + public CompiledScriptEntry(ICompiledBlockScript instance, CollectibleAssemblyLoadContext alc) + { + Instance = instance; + _alc = alc; + } + + /// + /// Gets whether the entry is alive (always true while not explicitly unloaded). + /// + public bool IsAlive => true; + + /// + /// Unloads the assembly context, releasing all memory associated with the compiled script. + /// + public void Unload() + { + try + { + _alc.Unload(); + } + catch (Exception ex) + { + Log.Debug(ex, "[CompiledScriptEntry] Error unloading assembly context"); + } + } +} + +/// +/// A collectible that allows compiled script assemblies +/// to be unloaded after use, preventing memory leaks during long-running workflow sessions. +/// +internal class CollectibleAssemblyLoadContext : AssemblyLoadContext +{ + /// + /// Initializes a new collectible assembly load context. + /// + /// The name for this context. + public CollectibleAssemblyLoadContext(string name) : base(name, isCollectible: true) { } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptAssemblyCompiler.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptAssemblyCompiler.cs index c5049730..d00d4ba8 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptAssemblyCompiler.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptAssemblyCompiler.cs @@ -1,34 +1,20 @@ using System; using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Reflection; -using System.Runtime.Loader; -using System.Text.Json; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; using KitX.Core.Workflow.Blueprint.Pipeline; using Serilog; -using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; - namespace KitX.Core.Workflow.BlockScripting; /// /// Compiles an entire into a .NET assembly using Roslyn /// . The generated assembly contains a single class -/// implementing with a Run method that -/// executes all blocks via a while(true) + switch(G.NextBlock) dispatcher. +/// implementing with a Run method. /// -/// Unified type conversion architecture ("超级电容"): Before code generation, -/// the script is formatted via to expand nested calls into -/// PubVar assignments. Type inference then determines each PubVar's target type from -/// downstream consumer signatures. ConvertTo<T> is inserted at data sources -/// (e.g., Get returns) where the source is object but the consumer needs -/// a specific type. This eliminates the need for __HelperFuncXxx object-param -/// wrappers — helper functions are called directly with typed PubVars. +/// This class acts as a coordinator, delegating to specialized components: +/// for Roslyn syntax generation, +/// for compilation, and +/// for disk caching. /// /// Compilation results are cached by script hash. Loaded assemblies use /// for unloadability. @@ -57,19 +43,20 @@ internal class ScriptAssemblyCompiler private readonly Dictionary _cache = new(); /// - /// JSON serializer options for meta.json persistence. + /// Persistence manager for disk caching. /// - private static readonly JsonSerializerOptions _metaJsonOptions = new() - { - WriteIndented = true, - PropertyNameCaseInsensitive = true, - }; + private readonly ScriptPersistenceManager _persistence; /// - /// Root directory for persisted compiled script assemblies. - /// Each workflow gets a subdirectory: Data/CompiledScripts/{workflow-id}/ + /// Initializes a new compiler instance with its persistence manager. /// - private static readonly string CompiledScriptsRoot = Path.Combine("./Data/", "CompiledScripts"); + public ScriptAssemblyCompiler() + { + _persistence = new ScriptPersistenceManager( + registerCacheEntry: (hash, entry) => _cache[hash] = entry, + getKitXVersion: () => typeof(ScriptAssemblyCompiler).Assembly.GetName().Version?.ToString() ?? "0.0.0.0", + tryGetCacheEntry: hash => _cache.TryGetValue(hash, out var entry) ? entry : null); + } // ────────────────────────────────────────────── // Public API @@ -86,16 +73,9 @@ internal class ScriptAssemblyCompiler /// Compiles a into an , /// with optional disk persistence for cross-session reuse. /// - /// The block script to compile. - /// - /// Optional workflow ID for disk persistence. When provided, the compiled assembly - /// is saved to Data/CompiledScripts/{workflowId}/{hash}.dll and reused on - /// subsequent calls instead of recompiling. - /// - /// Compiled script instance, or null on failure. public ICompiledBlockScript? CompileScript(BlockScript script, string? workflowId) { - var hash = ComputeScriptHash(script); + var hash = ScriptCompilationBackend.ComputeScriptHash(script); // Step 1: Check in-memory cache if (_cache.TryGetValue(hash, out var entry) && entry.IsAlive) @@ -107,7 +87,7 @@ internal class ScriptAssemblyCompiler // Step 2: Try loading from disk (if workflowId provided) if (workflowId != null) { - var diskInstance = TryLoadFromDisk(workflowId, hash); + var diskInstance = _persistence.TryLoadFromDisk(workflowId, hash); if (diskInstance != null) { Log.Debug("[ScriptAssemblyCompiler] Disk cache hit for hash '{Hash}' (workflow: {WfId})", @@ -116,17 +96,18 @@ internal class ScriptAssemblyCompiler } } - // Step 3: Roslyn compilation (existing flow) + // Step 3: Roslyn compilation try { // Phase 1: Format script + infer PubVar types var (formattedScript, pubVarTypes) = FormatAndInferTypes(script); - // Phase 2: Generate CompilationUnitSyntax using Roslyn SyntaxFactory - var compilationUnit = GenerateCompilationUnit(script, formattedScript, pubVarTypes, hash); + // Phase 2: Generate CompilationUnitSyntax + var compilationUnit = ScriptCodeGenerator.GenerateCompilationUnit( + script, formattedScript, pubVarTypes, hash); // Phase 3: Compile via CSharpCompilation - var assembly = CompileToAssembly(compilationUnit, hash); + var assembly = ScriptCompilationBackend.CompileToAssembly(compilationUnit, hash); if (assembly == null) return null; @@ -143,14 +124,12 @@ internal class ScriptAssemblyCompiler } var instance = (ICompiledBlockScript)Activator.CreateInstance(scriptType)!; - - // Cache in memory _cache[hash] = new CompiledScriptEntry(instance, alc); // Step 5: Persist to disk (if workflowId provided) if (workflowId != null) { - SaveToDisk(workflowId, hash, assembly, typeName); + _persistence.SaveToDisk(workflowId, hash, assembly, typeName); } Log.Debug("[ScriptAssemblyCompiler] Successfully compiled and cached script hash '{Hash}'", hash); @@ -173,204 +152,23 @@ public void ClearCache() _cache.Clear(); } - // ────────────────────────────────────────────── - // Disk persistence - // ────────────────────────────────────────────── - - /// - /// Attempts to load a compiled script assembly from disk. - /// Validates KitX version match before loading; stale assemblies are deleted. - /// - /// Loaded instance, or null if not found / stale / corrupt. - private ICompiledBlockScript? TryLoadFromDisk(string workflowId, string hash) - { - var dir = Path.Combine(CompiledScriptsRoot, workflowId); - var dllPath = Path.Combine(dir, $"{hash}.dll"); - var metaPath = Path.Combine(dir, $"{hash}.meta.json"); - - if (!File.Exists(dllPath) || !File.Exists(metaPath)) - return null; - - try - { - var metaJson = File.ReadAllText(metaPath); - var meta = JsonSerializer.Deserialize(metaJson, _metaJsonOptions); - if (meta == null) - { - Log.Debug("[ScriptAssemblyCompiler] Corrupt meta.json for hash '{Hash}', deleting", hash); - DeleteFromDisk(workflowId, hash); - return null; - } - - // Version validation: mismatch means API surface may have changed - var currentVersion = GetCurrentKitXVersion(); - if (meta.KitXVersion != currentVersion) - { - Log.Debug("[ScriptAssemblyCompiler] KitX version mismatch for hash '{Hash}': " + - "disk={DiskVer}, current={CurrentVer}. Deleting and recompiling.", - hash, meta.KitXVersion, currentVersion); - DeleteFromDisk(workflowId, hash); - return null; - } - - // Type name validation - if (string.IsNullOrEmpty(meta.TypeName)) - { - Log.Debug("[ScriptAssemblyCompiler] Missing TypeName in meta for hash '{Hash}', deleting", hash); - DeleteFromDisk(workflowId, hash); - return null; - } - - // Load assembly from disk - var dllBytes = File.ReadAllBytes(dllPath); - var alc = new CollectibleAssemblyLoadContext(hash); - var loadedAssembly = alc.LoadFromStream(new MemoryStream(dllBytes)); - - var scriptType = loadedAssembly.GetType(meta.TypeName); - if (scriptType == null) - { - Log.Debug("[ScriptAssemblyCompiler] Type '{TypeName}' not found in disk assembly for hash '{Hash}', deleting", - meta.TypeName, hash); - alc.Unload(); - DeleteFromDisk(workflowId, hash); - return null; - } - - var instance = (ICompiledBlockScript)Activator.CreateInstance(scriptType)!; - _cache[hash] = new CompiledScriptEntry(instance, alc); - - return instance; - } - catch (Exception ex) - { - Log.Debug(ex, "[ScriptAssemblyCompiler] Error loading from disk for hash '{Hash}', deleting", hash); - DeleteFromDisk(workflowId, hash); - return null; - } - } - - /// - /// Persists a compiled assembly and its metadata to disk. - /// - private void SaveToDisk(string workflowId, string hash, MemoryStream assemblyBytes, string typeName) - { - try - { - var dir = Path.Combine(CompiledScriptsRoot, workflowId); - Directory.CreateDirectory(dir); - - var dllPath = Path.Combine(dir, $"{hash}.dll"); - var metaPath = Path.Combine(dir, $"{hash}.meta.json"); - - // Write assembly bytes - File.WriteAllBytes(dllPath, assemblyBytes.ToArray()); - - // Write metadata - var meta = new CompiledScriptMeta - { - ScriptHash = hash, - CompileTimeUtc = DateTime.UtcNow, - KitXVersion = GetCurrentKitXVersion(), - TypeName = typeName - }; - var metaJson = JsonSerializer.Serialize(meta, _metaJsonOptions); - File.WriteAllText(metaPath, metaJson); - - Log.Debug("[ScriptAssemblyCompiler] Persisted compiled script hash '{Hash}' to disk (workflow: {WfId})", - hash, workflowId); - } - catch (Exception ex) - { - Log.Debug(ex, "[ScriptAssemblyCompiler] Failed to persist compiled script to disk for hash '{Hash}'", hash); - } - } - /// - /// Deletes persisted assembly files from disk. + /// Preloads all persisted compiled scripts for a given workflow from disk. /// - private static void DeleteFromDisk(string workflowId, string hash) - { - try - { - var dir = Path.Combine(CompiledScriptsRoot, workflowId); - var dllPath = Path.Combine(dir, $"{hash}.dll"); - var metaPath = Path.Combine(dir, $"{hash}.meta.json"); - - if (File.Exists(dllPath)) File.Delete(dllPath); - if (File.Exists(metaPath)) File.Delete(metaPath); - } - catch (Exception ex) - { - Log.Debug(ex, "[ScriptAssemblyCompiler] Error deleting disk cache for hash '{Hash}'", hash); - } - } - - /// - /// Preloads all persisted compiled scripts for a given workflow from disk - /// into the in-memory cache. Called at startup or when a workflow is first accessed. - /// - /// Workflow ID to preload scripts for. - /// Number of scripts successfully loaded into cache. - public int PreloadFromDisk(string workflowId) - { - var dir = Path.Combine(CompiledScriptsRoot, workflowId); - if (!Directory.Exists(dir)) - return 0; - - var count = 0; - foreach (var metaPath in Directory.GetFiles(dir, "*.meta.json")) - { - try - { - var metaJson = File.ReadAllText(metaPath); - var meta = JsonSerializer.Deserialize(metaJson, _metaJsonOptions); - if (meta == null || string.IsNullOrEmpty(meta.ScriptHash)) - continue; - - // Skip if already in memory cache - if (_cache.ContainsKey(meta.ScriptHash)) - continue; - - if (TryLoadFromDisk(workflowId, meta.ScriptHash) != null) - count++; - } - catch (Exception ex) - { - Log.Debug(ex, "[ScriptAssemblyCompiler] Error preloading from {Path}", metaPath); - } - } - - if (count > 0) - Log.Debug("[ScriptAssemblyCompiler] Preloaded {Count} compiled scripts for workflow {WfId}", - count, workflowId); - - return count; - } - - /// - /// Gets the current KitX assembly version string for disk cache invalidation. - /// - private static string GetCurrentKitXVersion() - { - return typeof(ScriptAssemblyCompiler).Assembly.GetName().Version?.ToString() ?? "0.0.0.0"; - } + public int PreloadFromDisk(string workflowId) => _persistence.PreloadFromDisk(workflowId); // ────────────────────────────────────────────── // Phase 1: Format + Type Inference // ────────────────────────────────────────────── /// - /// Formats the script using (expands nested calls into - /// PubVar assignments), then infers PubVar types from downstream consumer signatures. + /// Formats the script using and infers PubVar types. /// private (FormattedBlockScript formatted, Dictionary pubVarTypes) FormatAndInferTypes( BlockScript script) { var context = new PipelineContext { Script = script }; - // Pre-populate PubVarNames with PubVarBlock variable names so the ScriptFormatter - // recognizes existing PubVars and doesn't renumber them. Without this, BS→BP→BS - // round-trip scripts lose their PubVar assignments and produce broken FormattedStatements. if (script.PubVarBlock != null) { foreach (var variable in script.PubVarBlock.Variables) @@ -383,7 +181,6 @@ private static string GetCurrentKitXVersion() var formatter = new ScriptFormatter(script.HelperFunctions ?? [], FunctionRegistry); var formattedScript = formatter.Format(script, context); - // Debug: dump formatted script structure Log.Debug("[ScriptAssemblyCompiler] Formatted script: {BlockCount} blocks, MainBlock={Main}", formattedScript.Blocks.Count, formattedScript.MainBlockName); foreach (var block in formattedScript.Blocks) @@ -396,1123 +193,7 @@ private static string GetCurrentKitXVersion() string.Join(", ", stmt.Arguments), stmt.ConditionPubVar, stmt.SetVarName, stmt.GetVarName); } - var pubVarTypes = InferPubVarTypes(formattedScript, script.HelperFunctions, context); + var pubVarTypes = ScriptCodeGenerator.InferPubVarTypes(formattedScript, script.HelperFunctions, context); return (formattedScript, pubVarTypes); } - - /// - /// Infers PubVar types by analyzing downstream consumer signatures. - /// Two-pass algorithm: - /// - /// First pass: determine SOURCE type for each PubVar (Get → object, HelperFunc → ReturnType) - /// Second pass: determine DEMANDED type from consumers (Branch → bool, HelperFunc param → param type) - /// - /// ConvertTo<T> is needed when SOURCE is object but DEMANDED is a specific type. - /// - private Dictionary InferPubVarTypes( - FormattedBlockScript formattedScript, - List? helperFunctions, - PipelineContext context) - { - var pubVarTypes = new Dictionary(StringComparer.Ordinal); - var helperMap = (helperFunctions ?? []).ToDictionary(h => h.Name, h => h, StringComparer.Ordinal); - - // Initialize all PubVars to "object" (from PipelineContext.PubVarNames) - foreach (var name in context.PubVarNames) - pubVarTypes[name] = "object"; - - // ── First pass: SOURCE types ── - foreach (var block in formattedScript.Blocks) - { - foreach (var stmt in block.Statements) - { - if (stmt.PubVarTarget == null) continue; - - if (stmt.FunctionName == "Get") - { - // Get("var") always returns object - pubVarTypes[stmt.PubVarTarget] = "object"; - } - else if (helperMap.TryGetValue(stmt.FunctionName ?? "", out var helper)) - { - // Helper function call → ReturnType is known - pubVarTypes[stmt.PubVarTarget] = helper.ReturnType; - } - // Plugin calls: default to "object" (already set above) - // TODO: query IPluginManager.GetMethodSignature for plugin return types - } - } - - // ── Second pass: DEMANDED types from consumers ── - foreach (var block in formattedScript.Blocks) - { - foreach (var stmt in block.Statements) - { - // Branch/Loop condition demands bool - if ((stmt.Kind == FormattedStatementKind.Branch || stmt.Kind == FormattedStatementKind.Loop) - && !string.IsNullOrEmpty(stmt.ConditionPubVar) - && pubVarTypes.ContainsKey(stmt.ConditionPubVar)) - { - if (pubVarTypes[stmt.ConditionPubVar] == "object") - pubVarTypes[stmt.ConditionPubVar] = "bool"; - } - - // Helper function arguments demand specific types - if ((stmt.Kind == FormattedStatementKind.Assignment || stmt.Kind == FormattedStatementKind.Expression) - && stmt.FunctionName != null - && helperMap.TryGetValue(stmt.FunctionName, out var consumerHelper)) - { - for (int i = 0; i < stmt.Arguments.Count && i < consumerHelper.Parameters.Count; i++) - { - var arg = stmt.Arguments[i].Trim(); - if (pubVarTypes.ContainsKey(arg) && pubVarTypes[arg] == "object") - pubVarTypes[arg] = consumerHelper.Parameters[i].Type; - } - } - - // PluginCall arguments — TODO: query plugin method signature - // For now, PluginCall args that are PubVars remain "object" - } - } - - Log.Debug("[ScriptAssemblyCompiler] Type inference: {Count} PubVars typed: {Types}", - pubVarTypes.Count, - string.Join(", ", pubVarTypes.Select(kv => $"{kv.Key}={kv.Value}"))); - - return pubVarTypes; - } - - // ────────────────────────────────────────────── - // Phase 2: SyntaxFactory-based code generation - // ────────────────────────────────────────────── - - /// - /// Generates the complete for the compiled script. - /// Structure: - /// - /// namespace KitX.Core.Workflow.BlockScripting.Generated { - /// public class CompiledScript_<hash> : ICompiledBlockScript { - /// public static T ConvertTo<T>(object? value) { ... } - /// // Helper functions as static methods (typed, NO wrappers) - /// public void Run(BlockScriptExecutionGlobals G, CancellationToken ct) { - /// // Variable declarations + G.Set(...) sync - /// // while (true) { ct.ThrowIfCancellationRequested(); switch (G.NextBlock) { ... } } - /// } - /// } - /// } - /// - /// - private CompilationUnitSyntax GenerateCompilationUnit( - BlockScript script, - FormattedBlockScript formattedScript, - Dictionary pubVarTypes, - string hash) - { - var classDecl = ClassDeclaration($"CompiledScript_{hash}") - .AddModifiers(Token(SyntaxKind.PublicKeyword)) - .AddBaseListTypes( - SimpleBaseType(ParseTypeName(nameof(ICompiledBlockScript)))); - - // Add ConvertTo method - classDecl = classDecl.AddMembers(GenerateConvertToMethod()); - - // Add helper function methods (original typed versions ONLY — no __ wrappers) - var helperMembers = GenerateHelperFunctions(script.HelperFunctions); - if (helperMembers.Count > 0) - classDecl = classDecl.AddMembers(helperMembers.ToArray()); - - // Add Run method (from formatted script with typed PubVars) - var runMethod = GenerateRunMethod(script, formattedScript, pubVarTypes); - classDecl = classDecl.AddMembers(runMethod); - - var nsDecl = NamespaceDeclaration( - ParseName("KitX.Core.Workflow.BlockScripting.Generated")) - .AddMembers(classDecl); - - // Add usings - return CompilationUnit() - .AddUsings( - UsingDirective(ParseName("System")), - UsingDirective(ParseName("System.Threading")), - UsingDirective(ParseName("KitX.Core.Contract.Workflow")), - UsingDirective(ParseName("KitX.Core.Workflow.BlockScripting"))) - .AddMembers(nsDecl) - .NormalizeWhitespace(); - } - - /// - /// Generates the ConvertTo<T> static method — the universal type converter - /// that replaces all __HelperFuncXxx object-param wrappers. - /// - /// public static T ConvertTo<T>(object? value) - /// { - /// if (value is T t) return t; - /// if (value == null) return default!; - /// return (T)System.Convert.ChangeType(value, typeof(T)); - /// } - /// - /// - private MethodDeclarationSyntax GenerateConvertToMethod() - { - // Parameters: (object? value) - var valueParam = Parameter(Identifier("value")) - .WithType(NullableType(PredefinedType(Token(SyntaxKind.ObjectKeyword)))); - - var body = Block( - // if (value is T t) return t; - IfStatement( - IsPatternExpression( - IdentifierName("value"), - DeclarationPattern( - IdentifierName("T"), - SingleVariableDesignation(Identifier("t")))), - ReturnStatement(IdentifierName("t"))), - - // if (value == null) return default!; - IfStatement( - BinaryExpression(SyntaxKind.EqualsExpression, - IdentifierName("value"), - LiteralExpression(SyntaxKind.NullLiteralExpression)), - ReturnStatement( - PostfixUnaryExpression(SyntaxKind.SuppressNullableWarningExpression, - LiteralExpression(SyntaxKind.DefaultLiteralExpression)))), - - // return (T)System.Convert.ChangeType(value, typeof(T)); - ReturnStatement( - CastExpression(IdentifierName("T"), - InvocationExpression( - MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, - MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, - IdentifierName("System"), IdentifierName("Convert")), - IdentifierName("ChangeType")), - ArgumentList(SeparatedList(new ArgumentSyntax[] - { - Argument(IdentifierName("value")), - Argument(TypeOfExpression(IdentifierName("T"))) - })))) - ) - ); - - return MethodDeclaration(IdentifierName("T"), Identifier("ConvertTo")) - .AddModifiers(Token(SyntaxKind.PublicKeyword), Token(SyntaxKind.StaticKeyword)) - .WithTypeParameterList(TypeParameterList(SeparatedList(new[] { TypeParameter("T") }))) - .WithParameterList(ParameterList(SeparatedList(new[] { valueParam }))) - .WithBody(body); - } - - /// - /// Generates static method declarations from definitions. - /// Only generates the original typed methods — NO __-prefixed wrappers. - /// The wrappers are replaced by ConvertTo<T> at PubVar assignment sites. - /// - private List GenerateHelperFunctions(List? helperFunctions) - { - var members = new List(); - if (helperFunctions == null || helperFunctions.Count == 0) - return members; - - foreach (var func in helperFunctions) - { - var paramList = ParameterList(SeparatedList( - func.Parameters.Select(p => - Parameter(Identifier(p.Name)) - .WithType(ParseTypeName(p.Type))))); - - var bodyStatements = ParseHelperFunctionBody(func.Code); - - var methodDecl = MethodDeclaration( - ParseTypeName(func.ReturnType), - Identifier(func.Name)) - .AddModifiers(Token(SyntaxKind.PublicKeyword), Token(SyntaxKind.StaticKeyword)) - .WithParameterList(paramList) - .WithBody(Block(bodyStatements)); - - members.Add(methodDecl); - } - - return members; - } - - /// - /// Parses helper function body code into a list of . - /// - private static List ParseHelperFunctionBody(string? code) - { - if (string.IsNullOrWhiteSpace(code)) - return new List { ReturnStatement(LiteralExpression(SyntaxKind.NullLiteralExpression)) }; - - // Wrap body in a method for valid C# parsing, then extract statements - var wrapper = $"void __wrapper() {{ {code} }}"; - var tree = CSharpSyntaxTree.ParseText(wrapper); - var root = tree.GetCompilationUnitRoot(); - - if (root.Members.FirstOrDefault() is GlobalStatementSyntax gs - && gs.Statement is LocalFunctionStatementSyntax localFunc) - { - return localFunc.Body!.Statements.ToList(); - } - - // Fallback: try parsing as a block - var blockTree = CSharpSyntaxTree.ParseText($"{{ {code} }}"); - var blockRoot = blockTree.GetCompilationUnitRoot(); - if (blockRoot.Members.FirstOrDefault() is GlobalStatementSyntax gs2 - && gs2.Statement is BlockSyntax block) - { - return block.Statements.ToList(); - } - - Log.Warning("[ScriptAssemblyCompiler] Failed to parse helper function body, using empty body"); - return new List { ReturnStatement(LiteralExpression(SyntaxKind.NullLiteralExpression)) }; - } - - // ────────────────────────────────────────────── - // Run method generation (from FormattedBlockScript) - // ────────────────────────────────────────────── - - /// - /// Generates the Run method containing the while-switch dispatcher. - /// Code is generated from the with typed PubVars - /// and ConvertTo<T> where needed. - /// - private MethodDeclarationSyntax GenerateRunMethod( - BlockScript script, - FormattedBlockScript formattedScript, - Dictionary pubVarTypes) - { - var statements = new List(); - - // G.ResetRunState(); - statements.Add(ExpressionStatement( - InvocationExpression( - MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, - IdentifierName("G"), IdentifierName("ResetRunState"))))); - - // Variable declarations for ConstBlock/PubVarBlock - statements.AddRange(GenerateInitStatements(script)); - - // G.NextBlock = "MainBlock"; - statements.Add(ExpressionStatement( - AssignmentExpression(SyntaxKind.SimpleAssignmentExpression, - MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, - IdentifierName("G"), IdentifierName("NextBlock")), - LiteralExpression(SyntaxKind.StringLiteralExpression, - Literal(formattedScript.MainBlockName))))); - - // while (true) { ... } - var whileBody = new List(); - - // ct.ThrowIfCancellationRequested(); - whileBody.Add(ExpressionStatement( - InvocationExpression( - MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, - IdentifierName("ct"), IdentifierName("ThrowIfCancellationRequested"))))); - - // if (string.IsNullOrEmpty(G.NextBlock)) return; - whileBody.Add(IfStatement( - InvocationExpression( - MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, - ParseTypeName("string"), IdentifierName("IsNullOrEmpty")), - ArgumentList(SeparatedList(new[] - { - Argument(MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, - IdentifierName("G"), IdentifierName("NextBlock"))) - }))), - ReturnStatement())); - - // switch (G.NextBlock) { ... } - var switchSections = GenerateSwitchSections(formattedScript, pubVarTypes, script.HelperFunctions); - whileBody.Add(SwitchStatement( - MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, - IdentifierName("G"), IdentifierName("NextBlock")), - List(switchSections))); - - var whileStmt = WhileStatement( - LiteralExpression(SyntaxKind.TrueLiteralExpression), - Block(whileBody)); - - statements.Add(whileStmt); - - return MethodDeclaration( - PredefinedType(Token(SyntaxKind.VoidKeyword)), - Identifier("Run")) - .AddModifiers(Token(SyntaxKind.PublicKeyword)) - .WithParameterList(ParameterList(SeparatedList(new[] - { - Parameter(Identifier("G")) - .WithType(ParseTypeName(nameof(BlockScriptExecutionGlobals))), - Parameter(Identifier("ct")) - .WithType(ParseTypeName(nameof(CancellationToken))) - }))) - .WithBody(Block(statements)); - } - - /// - /// Generates variable initialization statements from ConstBlock/PubVarBlock. - /// - /// - /// Generates variable initialization statements from ConstBlock. - /// PubVarBlock variables are NOT declared in the outer scope — they get typed - /// declarations inside switch cases via type inference. Declaring them here would - /// cause CS0136 (variable already declared in enclosing scope). - /// Instead, we only emit G.Set("name", null) for PubVarBlock to initialize - /// the globals dictionary, without local variable declarations. - /// - private List GenerateInitStatements(BlockScript script) - { - var statements = new List(); - - // ConstBlock variables: full declaration + G.Set (they have known types and initial values) - if (script.ConstBlock != null) - { - foreach (var variable in script.ConstBlock.Variables) - statements.AddRange(GenerateVariableInit(variable)); - } - - // PubVarBlock variables: only G.Set("name", null) to initialize globals dictionary. - // No local variable declarations — these are declared with inferred types inside switch cases. - if (script.PubVarBlock != null) - { - foreach (var variable in script.PubVarBlock.Variables) - { - statements.Add(ExpressionStatement( - InvocationExpression( - MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, - IdentifierName("G"), IdentifierName("Set")), - ArgumentList(SeparatedList(new[] - { - Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, - Literal(variable.Name))), - Argument(LiteralExpression(SyntaxKind.NullLiteralExpression)) - }))))); - } - } - - return statements; - } - - /// - /// Generates statements for a single variable declaration. - /// - private List GenerateVariableInit(VariableDeclaration decl) - { - var stmts = new List(); - - if (decl.DefaultValue != null) - { - var initExpr = !string.IsNullOrEmpty(decl.InitialValueExpression) - ? ParseExpression(decl.InitialValueExpression) - : FormatLiteralExpression(decl.Type, decl.DefaultValue); - - stmts.Add(LocalDeclarationStatement( - VariableDeclaration(IdentifierName("var")) - .AddVariables( - VariableDeclarator(Identifier(decl.Name)) - .WithInitializer(EqualsValueClause(initExpr))))); - - stmts.Add(ExpressionStatement( - InvocationExpression( - MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, - IdentifierName("G"), IdentifierName("Set")), - ArgumentList(SeparatedList(new[] - { - Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(decl.Name))), - Argument(IdentifierName(decl.Name)) - }))))); - } - else if (!string.IsNullOrEmpty(decl.InitialValueExpression)) - { - stmts.Add(LocalDeclarationStatement( - VariableDeclaration(IdentifierName("var")) - .AddVariables( - VariableDeclarator(Identifier(decl.Name)) - .WithInitializer( - EqualsValueClause(ParseExpression(decl.InitialValueExpression)))))); - - stmts.Add(ExpressionStatement( - InvocationExpression( - MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, - IdentifierName("G"), IdentifierName("Set")), - ArgumentList(SeparatedList(new[] - { - Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(decl.Name))), - Argument(IdentifierName(decl.Name)) - }))))); - } - else - { - stmts.Add(LocalDeclarationStatement( - VariableDeclaration(ParseTypeName(decl.Type)) - .AddVariables(VariableDeclarator(Identifier(decl.Name))))); - - stmts.Add(ExpressionStatement( - InvocationExpression( - MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, - IdentifierName("G"), IdentifierName("Set")), - ArgumentList(SeparatedList(new[] - { - Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(decl.Name))), - Argument(LiteralExpression(SyntaxKind.NullLiteralExpression)) - }))))); - } - - return stmts; - } - - /// - /// Formats a literal value as a Roslyn . - /// - private static ExpressionSyntax FormatLiteralExpression(string type, object value) - { - return type switch - { - "string" => LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(value.ToString()!)), - "char" => LiteralExpression(SyntaxKind.CharacterLiteralExpression, Literal(char.Parse(value.ToString()!))), - "bool" => (bool)value - ? LiteralExpression(SyntaxKind.TrueLiteralExpression) - : LiteralExpression(SyntaxKind.FalseLiteralExpression), - "int" => LiteralExpression(SyntaxKind.NumericLiteralExpression, Literal((int)value)), - "long" => LiteralExpression(SyntaxKind.NumericLiteralExpression, Literal((long)value)), - "double" => LiteralExpression(SyntaxKind.NumericLiteralExpression, Literal((double)value)), - "float" => LiteralExpression(SyntaxKind.NumericLiteralExpression, Literal((float)value)), - _ => ParseExpression(value?.ToString() ?? "null") - }; - } - - // ────────────────────────────────────────────── - // Switch section generation (from FormattedBlock) - // ────────────────────────────────────────────── - - /// - /// Generates for each formatted block. - /// - private List GenerateSwitchSections( - FormattedBlockScript formattedScript, - Dictionary pubVarTypes, - List? helperFunctions) - { - var sections = new List(); - var helperReturnTypes = (helperFunctions ?? []) - .Where(h => h.Name != null) - .ToDictionary(h => h.Name!, h => h.ReturnType, StringComparer.Ordinal); - - foreach (var block in formattedScript.Blocks) - { - sections.Add(GenerateFormattedBlockCase(block, pubVarTypes, helperFunctions, helperReturnTypes)); - } - - // Default case: return (unknown block = end script) - sections.Add(SwitchSection() - .AddLabels(DefaultSwitchLabel()) - .AddStatements(ReturnStatement())); - - return sections; - } - - /// - /// Generates a single switch case from a . - /// Uses typed PubVars with ConvertTo<T> where the source is object - /// and the consumer needs a specific type. Helper functions are called directly - /// with typed arguments — no __-prefixed wrappers needed. - /// - private SwitchSectionSyntax GenerateFormattedBlockCase( - FormattedBlock block, - Dictionary pubVarTypes, - List? helperFunctions, - Dictionary helperReturnTypes) - { - var caseStatements = new List(); - - // G.ResetNextBlock(); - caseStatements.Add(ExpressionStatement( - InvocationExpression( - MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, - IdentifierName("G"), IdentifierName("ResetNextBlock"))))); - - // G.ExecutedBlockCount++; - caseStatements.Add(ExpressionStatement( - PostfixUnaryExpression(SyntaxKind.PostIncrementExpression, - MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, - IdentifierName("G"), IdentifierName("ExecutedBlockCount"))))); - - var hasNextBlockAssignment = false; - - foreach (var stmt in block.Statements) - { - switch (stmt.Kind) - { - case FormattedStatementKind.Assignment: - case FormattedStatementKind.Expression: - { - // ── Unified PubVar assignment: build raw expr, then wrap with ConvertTo if needed ── - // Both Assignment and Expression (promoted by ExpandCondition) produce PubVar declarations. - // The ConvertTo decision is always the same: if source returns object but target needs - // a specific type, wrap it. No per-source-type branching needed. - - // Step 1: Build raw call expression - ExpressionSyntax rawExpr; - string sourceType = "object"; // default: unknown source returns object - - if (stmt.FunctionName == "Get") - { - rawExpr = BuildGetInvocation(stmt.GetVarName ?? ""); - // Get always returns object - } - else if (IsHelperFunction(stmt.FunctionName, helperFunctions)) - { - var args = stmt.Arguments.Select(a => - Argument(ResolveArgumentExpression(a, pubVarTypes))).ToList(); - rawExpr = InvocationExpression(IdentifierName(stmt.FunctionName!), - ArgumentList(SeparatedList(args))); - // Helper source type = ReturnType - sourceType = helperReturnTypes.TryGetValue(stmt.FunctionName ?? "", out var rt) - ? rt : "object"; - } - else if (stmt.FunctionName == "PluginCallWithTarget") - { - // PluginCallWithTarget("plugin", "method", "device", args...) - // Must check BEFORE FullFunctionName.Contains('.') since - // "G.PluginCallWithTarget" also contains a dot. - rawExpr = BuildPluginCallWithTargetExpression(stmt, pubVarTypes); - } - else if (stmt.FullFunctionName != null && stmt.FullFunctionName.Contains('.')) - { - rawExpr = BuildPluginCallExpression(stmt, pubVarTypes); - // PluginCall returns object (until we query method signatures) - } - else - { - rawExpr = ParseExpression( - $"{stmt.FunctionName}({string.Join(", ", stmt.Arguments)})"); - // Unknown function, assume object - } - - // Step 2: If there's a PubVarTarget, emit typed declaration with ConvertTo if needed - if (stmt.PubVarTarget != null) - { - var typeName = pubVarTypes.GetValueOrDefault(stmt.PubVarTarget, "object"); - - // Unified ConvertTo rule: source is object but target is specific → wrap - ExpressionSyntax initExpr = (typeName != "object" && sourceType == "object") - ? BuildConvertToInvocation(typeName, rawExpr) - : rawExpr; - - caseStatements.Add(LocalDeclarationStatement( - VariableDeclaration(ParseTypeName(typeName)) - .AddVariables(VariableDeclarator(Identifier(stmt.PubVarTarget)) - .WithInitializer(EqualsValueClause(initExpr))))); - } - else - { - // No PubVar → standalone expression statement - caseStatements.Add(ExpressionStatement(rawExpr)); - } - - break; - } - - case FormattedStatementKind.Branch: - { - // G.NextBlock = G.Branch(cond, "TrueBlock", "FalseBlock"); - var condExpr = !string.IsNullOrEmpty(stmt.ConditionPubVar) - ? ResolveArgumentExpression(stmt.ConditionPubVar, pubVarTypes) - : ParseExpression(stmt.ConditionExpression ?? "false"); - - caseStatements.Add(ExpressionStatement( - AssignmentExpression(SyntaxKind.SimpleAssignmentExpression, - MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, - IdentifierName("G"), IdentifierName("NextBlock")), - InvocationExpression( - MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, - IdentifierName("G"), IdentifierName("Branch")), - ArgumentList(SeparatedList(new[] - { - Argument(condExpr), - Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, - Literal(stmt.TrueBlockName ?? ""))), - Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, - Literal(stmt.FalseBlockName ?? ""))) - })))))); - - hasNextBlockAssignment = true; - caseStatements.Add(BreakStatement()); // break exits switch → loops back to while - break; - } - - case FormattedStatementKind.Loop: - { - // G.NextBlock = G.Loop(cond, "LoopBody", "LoopEnd"); - var condExpr = !string.IsNullOrEmpty(stmt.ConditionPubVar) - ? ResolveArgumentExpression(stmt.ConditionPubVar, pubVarTypes) - : ParseExpression(stmt.ConditionExpression ?? "false"); - - caseStatements.Add(ExpressionStatement( - AssignmentExpression(SyntaxKind.SimpleAssignmentExpression, - MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, - IdentifierName("G"), IdentifierName("NextBlock")), - InvocationExpression( - MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, - IdentifierName("G"), IdentifierName("Loop")), - ArgumentList(SeparatedList(new[] - { - Argument(condExpr), - Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, - Literal(stmt.TrueBlockName ?? ""))), - Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, - Literal(stmt.FalseBlockName ?? ""))) - })))))); - - hasNextBlockAssignment = true; - caseStatements.Add(BreakStatement()); - break; - } - - case FormattedStatementKind.Print: - { - // G.Print(arg); - if (stmt.Arguments.Count > 0) - { - var argExpr = ResolveArgumentExpression(stmt.Arguments[0], pubVarTypes); - caseStatements.Add( - ExpressionStatement( - InvocationExpression( - MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, - IdentifierName("G"), IdentifierName("Print")), - ArgumentList(SeparatedList(new[] { Argument(argExpr) }))))); - } - - break; - } - - case FormattedStatementKind.PluginCallWithTarget: - { - // G.PluginCallWithTarget("pluginName", "methodName", targetDeviceExpr[, arg1, arg2, ...]); - // Arguments[0]=pluginName, [1]=methodName, [2]=targetDevice, [3...]=call args - // targetDeviceExpr may be a string literal or a variable (e.g. TryGetDevice(...) result) - var args = new List(); - - // pluginName (arg 0) - if (stmt.Arguments.Count > 0) - args.Add(Argument(ResolveArgumentExpression(stmt.Arguments[0], pubVarTypes))); - else - args.Add(Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, Literal("")))); - - // methodName (arg 1) - if (stmt.Arguments.Count > 1) - args.Add(Argument(ResolveArgumentExpression(stmt.Arguments[1], pubVarTypes))); - else - args.Add(Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, Literal("")))); - - // targetDevice (arg 2) — may be a variable reference or TryGetDevice(...) call - if (stmt.Arguments.Count > 2) - args.Add(Argument(ResolveArgumentExpression(stmt.Arguments[2], pubVarTypes))); - else - args.Add(Argument(LiteralExpression(SyntaxKind.NullLiteralExpression))); - - // Extra call args (arg 3+) - for (int i = 3; i < stmt.Arguments.Count; i++) - args.Add(Argument(ResolveArgumentExpression(stmt.Arguments[i], pubVarTypes))); - - caseStatements.Add( - ExpressionStatement( - InvocationExpression( - MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, - IdentifierName("G"), IdentifierName("PluginCallWithTarget")), - ArgumentList(SeparatedList(args))))); - - break; - } - - case FormattedStatementKind.TryGetDevice: - { - // var pubVar = G.TryGetDevice("pattern"); - if (stmt.Arguments.Count > 0 && !string.IsNullOrEmpty(stmt.PubVarTarget)) - { - var patternExpr = ResolveArgumentExpression(stmt.Arguments[0], pubVarTypes); - caseStatements.Add( - LocalDeclarationStatement( - VariableDeclaration(IdentifierName("var")) - .AddVariables( - VariableDeclarator(Identifier(stmt.PubVarTarget)) - .WithInitializer( - EqualsValueClause( - InvocationExpression( - MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, - IdentifierName("G"), IdentifierName("TryGetDevice")), - ArgumentList(SeparatedList(new[] { Argument(patternExpr) })))))))); - } - - break; - } - - case FormattedStatementKind.Set: - { - // G.Set("varName", value); - var varName = stmt.SetVarName ?? ""; - if (stmt.Arguments.Count > 0) - { - var valueExpr = ResolveArgumentExpression(stmt.Arguments[0], pubVarTypes); - caseStatements.Add( - ExpressionStatement( - InvocationExpression( - MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, - IdentifierName("G"), IdentifierName("Set")), - ArgumentList(SeparatedList(new[] - { - Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(varName))), - Argument(valueExpr) - }))))); - } - - break; - } - - case FormattedStatementKind.ToLoopCond: - { - // G.NextBlock = G.ToLoopCond("parentBlock"); - caseStatements.Add( - ExpressionStatement( - AssignmentExpression(SyntaxKind.SimpleAssignmentExpression, - MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, - IdentifierName("G"), IdentifierName("NextBlock")), - InvocationExpression( - MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, - IdentifierName("G"), IdentifierName("ToLoopCond")), - ArgumentList(SeparatedList(new[] - { - Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, - Literal(stmt.ToLoopCondReturnTo ?? ""))) - })))))); - - hasNextBlockAssignment = true; - caseStatements.Add(BreakStatement()); - break; - } - - case FormattedStatementKind.Break: - { - // Exit the Run method entirely - caseStatements.Add(ReturnStatement()); - break; - } - - case FormattedStatementKind.Pause: - { - // G.Pause(ms); - if (stmt.Arguments.Count > 0) - { - var msExpr = ResolveArgumentExpression(stmt.Arguments[0], pubVarTypes); - caseStatements.Add( - ExpressionStatement( - InvocationExpression( - MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, - IdentifierName("G"), IdentifierName("Pause")), - ArgumentList(SeparatedList(new[] { Argument(msExpr) }))))); - } - - break; - } - - default: - // Unknown FormattedStatementKind — skip - break; - } - } - - // Auto-complete NextBlock if block has NextBlockName and no explicit assignment - if (!hasNextBlockAssignment && !string.IsNullOrEmpty(block.NextBlockName)) - { - caseStatements.Add(ExpressionStatement( - AssignmentExpression(SyntaxKind.SimpleAssignmentExpression, - MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, - IdentifierName("G"), IdentifierName("NextBlock")), - LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(block.NextBlockName))))); - } - - // Trailing break: exits switch → continues to while(true) top. - // Skip if the case already ends with break (flow control) or return. - if (!caseStatements.Any(s => s is BreakStatementSyntax or ReturnStatementSyntax)) - caseStatements.Add(BreakStatement()); - - return SwitchSection() - .AddLabels(CaseSwitchLabel( - LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(block.Name)))) - .AddStatements(caseStatements.ToArray()); - } - - // ────────────────────────────────────────────── - // Expression builders - // ────────────────────────────────────────────── - - /// - /// Builds ConvertTo<T>(arg) expression. - /// Uses for the method name so the type argument - /// is part of the expression, not a separate parameter to . - /// - private static InvocationExpressionSyntax BuildConvertToInvocation(string typeName, ExpressionSyntax argExpr) - { - return InvocationExpression( - GenericName(Identifier("ConvertTo"), - TypeArgumentList(SeparatedList(new TypeSyntax[] { ParseTypeName(typeName) }))), - ArgumentList(SeparatedList(new[] { Argument(argExpr) }))); - } - - /// - /// Builds G.Get<object>("varName") expression. - /// - private static InvocationExpressionSyntax BuildGetInvocation(string varName) - { - return InvocationExpression( - MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, - IdentifierName("G"), - GenericName(Identifier("Get"), - TypeArgumentList(SeparatedList(new TypeSyntax[] - { PredefinedType(Token(SyntaxKind.ObjectKeyword)) })))), - ArgumentList(SeparatedList(new[] - { - Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(varName))) - }))); - } - - /// - /// Builds G.PluginCall("pluginName", "methodName", args...) expression - /// from a with a dotted . - /// - private static InvocationExpressionSyntax BuildPluginCallExpression( - FormattedStatement stmt, Dictionary pubVarTypes) - { - var lastDot = (stmt.FullFunctionName ?? "").LastIndexOf('.'); - var pluginName = lastDot >= 0 ? stmt.FullFunctionName![..lastDot] : stmt.FullFunctionName ?? ""; - var methodName = lastDot >= 0 ? stmt.FullFunctionName![(lastDot + 1)..] : ""; - - var pluginCallArgs = new List - { - Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(pluginName))), - Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(methodName))) - }; - pluginCallArgs.AddRange(stmt.Arguments.Select(a => - Argument(ResolveArgumentExpression(a, pubVarTypes)))); - - return InvocationExpression( - MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, - IdentifierName("G"), IdentifierName("PluginCall")), - ArgumentList(SeparatedList(pluginCallArgs))); - } - - /// - /// Builds G.PluginCallWithTarget("pluginName", "methodName", "targetDevice", args...) - /// expression for cross-device plugin calls. - /// - private static InvocationExpressionSyntax BuildPluginCallWithTargetExpression( - FormattedStatement stmt, Dictionary pubVarTypes) - { - // Arguments: [0]=pluginName, [1]=methodName, [2]=targetDevice, [3...]=call args - var pluginNameArg = stmt.Arguments.Count > 0 - ? stmt.Arguments[0] : "\"\""; - var methodNameArg = stmt.Arguments.Count > 1 - ? stmt.Arguments[1] : "\"\""; - var targetDeviceArg = stmt.Arguments.Count > 2 - ? stmt.Arguments[2] : "\"\""; - var callArgs = stmt.Arguments.Count > 3 - ? stmt.Arguments.Skip(3).ToList() - : new List(); - - var args = new List - { - Argument(ResolveArgumentExpression(pluginNameArg, pubVarTypes)), - Argument(ResolveArgumentExpression(methodNameArg, pubVarTypes)), - Argument(ResolveArgumentExpression(targetDeviceArg, pubVarTypes)) - }; - args.AddRange(callArgs.Select(a => - Argument(ResolveArgumentExpression(a, pubVarTypes)))); - - return InvocationExpression( - MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, - IdentifierName("G"), IdentifierName("PluginCallWithTarget")), - ArgumentList(SeparatedList(args))); - } - - /// - /// Resolves a formatted argument string into a Roslyn . - /// PubVar references are returned as . - /// All other values are parsed via . - /// - private static ExpressionSyntax ResolveArgumentExpression( - string arg, Dictionary pubVarTypes) - { - arg = arg.Trim(); - - // PubVar reference — always use as identifier - if (pubVarTypes.ContainsKey(arg)) - return IdentifierName(arg); - - // Fallback: parse as C# expression (handles literals, variable names, etc.) - var parsed = ParseExpression(arg); - return parsed ?? IdentifierName(arg); - } - - /// - /// Checks whether a function name corresponds to a registered HelperFunction. - /// - private static bool IsHelperFunction(string? name, List? helperFunctions) - { - if (name == null || helperFunctions == null) return false; - return helperFunctions.Any(h => h.Name == name); - } - - // ────────────────────────────────────────────── - // Phase 3: Roslyn compilation - // ────────────────────────────────────────────── - - /// - /// Compiles the generated into a .NET assembly. - /// - private MemoryStream? CompileToAssembly(CompilationUnitSyntax compilationUnit, string hash) - { - var normalized = compilationUnit.NormalizeWhitespace(); - var sourceText = normalized.ToFullString(); - - Log.Debug("[ScriptAssemblyCompiler] Generated source code for hash '{Hash}':\n{Source}", hash, sourceText); - - var syntaxTree = CSharpSyntaxTree.Create(normalized, - path: $"CompiledScript_{hash}.cs"); - - var compilation = CSharpCompilation.Create( - $"CompiledScript_{hash}", - syntaxTrees: new[] { syntaxTree }, - references: GetCompilationReferences(), - options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) - .WithOptimizationLevel(OptimizationLevel.Release) - .WithAssemblyIdentityComparer(DesktopAssemblyIdentityComparer.Default)); - - var assemblyStream = new MemoryStream(); - var emitResult = compilation.Emit(assemblyStream); - - if (!emitResult.Success) - { - var diagnostics = emitResult.Diagnostics - .Where(d => d.Severity >= DiagnosticSeverity.Error) - .Select(d => d.ToString()) - .ToList(); - - Log.Warning("[ScriptAssemblyCompiler] Compilation failed with {ErrorCount} errors:", - diagnostics.Count); - foreach (var diag in diagnostics.Take(10)) - { - Log.Warning("[ScriptAssemblyCompiler] {Diagnostic}", diag); - } - - return null; - } - - assemblyStream.Position = 0; - Log.Debug("[ScriptAssemblyCompiler] Assembly compiled successfully: {Size} bytes", - assemblyStream.Length); - return assemblyStream; - } - - /// - /// Gets the set of s needed for compilation. - /// - private static List GetCompilationReferences() - { - var references = new List(); - var seen = new HashSet(StringComparer.OrdinalIgnoreCase); - - var seedAssemblies = new Assembly[] - { - typeof(BlockScriptExecutionGlobals).Assembly, - typeof(KitX.Core.Contract.Workflow.BlockScript).Assembly, - typeof(ICompiledBlockScript).Assembly, - typeof(KitX.Core.Contract.Workflow.PluginCallInfo).Assembly, - typeof(Microsoft.CSharp.RuntimeBinder.Binder).Assembly, - typeof(object).Assembly, - typeof(System.Collections.Generic.List<>).Assembly, - typeof(System.Linq.Enumerable).Assembly, - }; - - foreach (var assembly in seedAssemblies) - { - if (string.IsNullOrEmpty(assembly.Location)) continue; - if (!seen.Add(assembly.Location)) continue; - references.Add(MetadataReference.CreateFromFile(assembly.Location)); - } - - foreach (var assembly in AssemblyLoadContext.Default.Assemblies) - { - if (assembly.IsDynamic || string.IsNullOrEmpty(assembly.Location)) continue; - if (!seen.Add(assembly.Location)) continue; - - references.Add(MetadataReference.CreateFromFile(assembly.Location)); - } - - Log.Debug("[ScriptAssemblyCompiler] Compilation references: {Count} assemblies", references.Count); - return references; - } - - // ────────────────────────────────────────────── - // Utilities - // ────────────────────────────────────────────── - - /// - /// Computes a deterministic hash for a BlockScript based on its structural content. - /// - private static string ComputeScriptHash(BlockScript script) - { - var hashInput = new System.Text.StringBuilder(); - - foreach (var block in script.AllBlocks) - { - hashInput.Append($"[{block.Name}:{block.Type}:{block.NextBlockName}]"); - foreach (var stmt in block.Statements) - { - hashInput.Append($"<{stmt.SourceCode}>"); - } - } - - foreach (var helper in script.HelperFunctions ?? []) - { - hashInput.Append($"{{H:{helper.Name}:{helper.Code}}}"); - } - - var hash = 0; - foreach (var c in hashInput.ToString()) - hash = (hash * 31 + c) & 0x7FFFFFFF; - - return hash.ToString("x8"); - } - - // ────────────────────────────────────────────── - // CollectibleAssemblyLoadContext - // ────────────────────────────────────────────── - - private class CollectibleAssemblyLoadContext : AssemblyLoadContext - { - public CollectibleAssemblyLoadContext(string name) : base(name, isCollectible: true) { } - } - - private class CompiledScriptEntry - { - public ICompiledBlockScript Instance { get; } - private readonly CollectibleAssemblyLoadContext _alc; - - public CompiledScriptEntry(ICompiledBlockScript instance, CollectibleAssemblyLoadContext alc) - { - Instance = instance; - _alc = alc; - } - - public bool IsAlive => true; - - public void Unload() - { - try - { - _alc.Unload(); - } - catch (Exception ex) - { - Log.Debug(ex, "[ScriptAssemblyCompiler] Error unloading assembly context"); - } - } - } -} \ No newline at end of file +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptCodeGenerator.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptCodeGenerator.cs new file mode 100644 index 00000000..afff66fc --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptCodeGenerator.cs @@ -0,0 +1,884 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.Loader; +using System.Threading; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using KitX.Core.Contract.Workflow; +using KitX.Core.Workflow.Blueprint.Pipeline; +using Serilog; + +using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; + +namespace KitX.Core.Workflow.BlockScripting; + +/// +/// Generates Roslyn from a . +/// Stateless — all methods are pure code generation functions. +/// +/// The generated class implements with a Run method +/// that executes all blocks via a while(true) + switch(G.NextBlock) dispatcher. +/// +internal static class ScriptCodeGenerator +{ + // ────────────────────────────────────────────── + // Type inference + // ────────────────────────────────────────────── + + /// + /// Infers PubVar types by analyzing downstream consumer signatures. + /// Two-pass algorithm: + /// + /// First pass: determine SOURCE type for each PubVar (Get → object, HelperFunc → ReturnType) + /// Second pass: determine DEMANDED type from consumers (Branch → bool, HelperFunc param → param type) + /// + /// ConvertTo<T> is needed when SOURCE is object but DEMANDED is a specific type. + /// + internal static Dictionary InferPubVarTypes( + FormattedBlockScript formattedScript, + List? helperFunctions, + PipelineContext context) + { + var pubVarTypes = new Dictionary(StringComparer.Ordinal); + var helperMap = (helperFunctions ?? []).ToDictionary(h => h.Name, h => h, StringComparer.Ordinal); + + // Initialize all PubVars to "object" + foreach (var name in context.PubVarNames) + pubVarTypes[name] = "object"; + + // First pass: SOURCE types + foreach (var block in formattedScript.Blocks) + { + foreach (var stmt in block.Statements) + { + if (stmt.PubVarTarget == null) continue; + + if (stmt.FunctionName == "Get") + { + pubVarTypes[stmt.PubVarTarget] = "object"; + } + else if (helperMap.TryGetValue(stmt.FunctionName ?? "", out var helper)) + { + pubVarTypes[stmt.PubVarTarget] = helper.ReturnType; + } + } + } + + // Second pass: DEMANDED types from consumers + foreach (var block in formattedScript.Blocks) + { + foreach (var stmt in block.Statements) + { + // Branch/Loop condition demands bool + if ((stmt.Kind == FormattedStatementKind.Branch || stmt.Kind == FormattedStatementKind.Loop) + && !string.IsNullOrEmpty(stmt.ConditionPubVar) + && pubVarTypes.ContainsKey(stmt.ConditionPubVar)) + { + if (pubVarTypes[stmt.ConditionPubVar] == "object") + pubVarTypes[stmt.ConditionPubVar] = "bool"; + } + + // Helper function arguments demand specific types + if ((stmt.Kind == FormattedStatementKind.Assignment || stmt.Kind == FormattedStatementKind.Expression) + && stmt.FunctionName != null + && helperMap.TryGetValue(stmt.FunctionName, out var consumerHelper)) + { + for (int i = 0; i < stmt.Arguments.Count && i < consumerHelper.Parameters.Count; i++) + { + var arg = stmt.Arguments[i].Trim(); + if (pubVarTypes.ContainsKey(arg) && pubVarTypes[arg] == "object") + pubVarTypes[arg] = consumerHelper.Parameters[i].Type; + } + } + } + } + + Log.Debug("[ScriptCodeGenerator] Type inference: {Count} PubVars typed: {Types}", + pubVarTypes.Count, + string.Join(", ", pubVarTypes.Select(kv => $"{kv.Key}={kv.Value}"))); + + return pubVarTypes; + } + + // ────────────────────────────────────────────── + // Compilation unit generation + // ────────────────────────────────────────────── + + /// + /// Generates the complete for the compiled script. + /// Structure: + /// + /// namespace KitX.Core.Workflow.BlockScripting.Generated { + /// public class CompiledScript_<hash> : ICompiledBlockScript { + /// public static T ConvertTo<T>(object? value) { ... } + /// // Helper functions as static methods (typed, NO wrappers) + /// public void Run(BlockScriptExecutionGlobals G, CancellationToken ct) { + /// // Variable declarations + G.Set(...) sync + /// // while (true) { ct.ThrowIfCancellationRequested(); switch (G.NextBlock) { ... } } + /// } + /// } + /// } + /// + /// + internal static CompilationUnitSyntax GenerateCompilationUnit( + BlockScript script, + FormattedBlockScript formattedScript, + Dictionary pubVarTypes, + string hash) + { + var classDecl = ClassDeclaration($"CompiledScript_{hash}") + .AddModifiers(Token(SyntaxKind.PublicKeyword)) + .AddBaseListTypes( + SimpleBaseType(ParseTypeName(nameof(ICompiledBlockScript)))); + + classDecl = classDecl.AddMembers(GenerateConvertToMethod()); + + var helperMembers = GenerateHelperFunctions(script.HelperFunctions); + if (helperMembers.Count > 0) + classDecl = classDecl.AddMembers(helperMembers.ToArray()); + + var runMethod = GenerateRunMethod(script, formattedScript, pubVarTypes); + classDecl = classDecl.AddMembers(runMethod); + + var nsDecl = NamespaceDeclaration( + ParseName("KitX.Core.Workflow.BlockScripting.Generated")) + .AddMembers(classDecl); + + return CompilationUnit() + .AddUsings( + UsingDirective(ParseName("System")), + UsingDirective(ParseName("System.Threading")), + UsingDirective(ParseName("KitX.Core.Contract.Workflow")), + UsingDirective(ParseName("KitX.Core.Workflow.BlockScripting"))) + .AddMembers(nsDecl) + .NormalizeWhitespace(); + } + + /// + /// Generates the ConvertTo<T> static method. + /// + private static MethodDeclarationSyntax GenerateConvertToMethod() + { + var valueParam = Parameter(Identifier("value")) + .WithType(NullableType(PredefinedType(Token(SyntaxKind.ObjectKeyword)))); + + var body = Block( + IfStatement( + IsPatternExpression( + IdentifierName("value"), + DeclarationPattern(IdentifierName("T"), SingleVariableDesignation(Identifier("t")))), + ReturnStatement(IdentifierName("t"))), + IfStatement( + BinaryExpression(SyntaxKind.EqualsExpression, + IdentifierName("value"), + LiteralExpression(SyntaxKind.NullLiteralExpression)), + ReturnStatement( + PostfixUnaryExpression(SyntaxKind.SuppressNullableWarningExpression, + LiteralExpression(SyntaxKind.DefaultLiteralExpression)))), + ReturnStatement( + CastExpression(IdentifierName("T"), + InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("System"), IdentifierName("Convert")), + IdentifierName("ChangeType")), + ArgumentList(SeparatedList(new ArgumentSyntax[] + { + Argument(IdentifierName("value")), + Argument(TypeOfExpression(IdentifierName("T"))) + }))))) + ); + + return MethodDeclaration(IdentifierName("T"), Identifier("ConvertTo")) + .AddModifiers(Token(SyntaxKind.PublicKeyword), Token(SyntaxKind.StaticKeyword)) + .WithTypeParameterList(TypeParameterList(SeparatedList(new[] { TypeParameter("T") }))) + .WithParameterList(ParameterList(SeparatedList(new[] { valueParam }))) + .WithBody(body); + } + + /// + /// Generates static method declarations from definitions. + /// + private static List GenerateHelperFunctions(List? helperFunctions) + { + var members = new List(); + if (helperFunctions == null || helperFunctions.Count == 0) + return members; + + foreach (var func in helperFunctions) + { + var paramList = ParameterList(SeparatedList( + func.Parameters.Select(p => + Parameter(Identifier(p.Name)) + .WithType(ParseTypeName(p.Type))))); + + var bodyStatements = ParseHelperFunctionBody(func.Code); + + var methodDecl = MethodDeclaration( + ParseTypeName(func.ReturnType), + Identifier(func.Name)) + .AddModifiers(Token(SyntaxKind.PublicKeyword), Token(SyntaxKind.StaticKeyword)) + .WithParameterList(paramList) + .WithBody(Block(bodyStatements)); + + members.Add(methodDecl); + } + + return members; + } + + /// + /// Parses helper function body code into a list of . + /// + internal static List ParseHelperFunctionBody(string? code) + { + if (string.IsNullOrWhiteSpace(code)) + return new List { ReturnStatement(LiteralExpression(SyntaxKind.NullLiteralExpression)) }; + + var wrapper = $"void __wrapper() {{ {code} }}"; + var tree = CSharpSyntaxTree.ParseText(wrapper); + var root = tree.GetCompilationUnitRoot(); + + if (root.Members.FirstOrDefault() is GlobalStatementSyntax gs + && gs.Statement is LocalFunctionStatementSyntax localFunc) + { + return localFunc.Body!.Statements.ToList(); + } + + var blockTree = CSharpSyntaxTree.ParseText($"{{ {code} }}"); + var blockRoot = blockTree.GetCompilationUnitRoot(); + if (blockRoot.Members.FirstOrDefault() is GlobalStatementSyntax gs2 + && gs2.Statement is BlockSyntax block) + { + return block.Statements.ToList(); + } + + Log.Warning("[ScriptCodeGenerator] Failed to parse helper function body, using empty body"); + return new List { ReturnStatement(LiteralExpression(SyntaxKind.NullLiteralExpression)) }; + } + + // ────────────────────────────────────────────── + // Run method generation + // ────────────────────────────────────────────── + + /// + /// Generates the Run method with while-switch dispatcher. + /// + internal static MethodDeclarationSyntax GenerateRunMethod( + BlockScript script, + FormattedBlockScript formattedScript, + Dictionary pubVarTypes) + { + var statements = new List(); + + // G.ResetRunState(); + statements.Add(ExpressionStatement( + InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("G"), IdentifierName("ResetRunState"))))); + + statements.AddRange(GenerateInitStatements(script)); + + // G.NextBlock = "MainBlock"; + statements.Add(ExpressionStatement( + AssignmentExpression(SyntaxKind.SimpleAssignmentExpression, + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("G"), IdentifierName("NextBlock")), + LiteralExpression(SyntaxKind.StringLiteralExpression, + Literal(formattedScript.MainBlockName))))); + + // while (true) { ct.ThrowIfCancellationRequested(); if (IsNullOrEmpty(G.NextBlock)) return; switch (G.NextBlock) { ... } } + var whileBody = new List(); + + whileBody.Add(ExpressionStatement( + InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("ct"), IdentifierName("ThrowIfCancellationRequested"))))); + + whileBody.Add(IfStatement( + InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + ParseTypeName("string"), IdentifierName("IsNullOrEmpty")), + ArgumentList(SeparatedList(new[] + { + Argument(MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("G"), IdentifierName("NextBlock"))) + }))), + ReturnStatement())); + + var switchSections = GenerateSwitchSections(formattedScript, pubVarTypes, script.HelperFunctions); + whileBody.Add(SwitchStatement( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("G"), IdentifierName("NextBlock")), + List(switchSections))); + + statements.Add(WhileStatement( + LiteralExpression(SyntaxKind.TrueLiteralExpression), + Block(whileBody))); + + return MethodDeclaration( + PredefinedType(Token(SyntaxKind.VoidKeyword)), + Identifier("Run")) + .AddModifiers(Token(SyntaxKind.PublicKeyword)) + .WithParameterList(ParameterList(SeparatedList(new[] + { + Parameter(Identifier("G")) + .WithType(ParseTypeName(nameof(BlockScriptExecutionGlobals))), + Parameter(Identifier("ct")) + .WithType(ParseTypeName(nameof(CancellationToken))) + }))) + .WithBody(Block(statements)); + } + + /// + /// Generates variable initialization statements from ConstBlock/PubVarBlock. + /// + internal static List GenerateInitStatements(BlockScript script) + { + var statements = new List(); + + if (script.ConstBlock != null) + { + foreach (var variable in script.ConstBlock.Variables) + statements.AddRange(GenerateVariableInit(variable)); + } + + if (script.PubVarBlock != null) + { + foreach (var variable in script.PubVarBlock.Variables) + { + statements.Add(ExpressionStatement( + InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("G"), IdentifierName("Set")), + ArgumentList(SeparatedList(new[] + { + Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, + Literal(variable.Name))), + Argument(LiteralExpression(SyntaxKind.NullLiteralExpression)) + }))))); + } + } + + return statements; + } + + /// + /// Generates statements for a single variable declaration. + /// + internal static List GenerateVariableInit(VariableDeclaration decl) + { + var stmts = new List(); + + if (decl.DefaultValue != null) + { + var initExpr = !string.IsNullOrEmpty(decl.InitialValueExpression) + ? ParseExpression(decl.InitialValueExpression) + : FormatLiteralExpression(decl.Type, decl.DefaultValue); + + stmts.Add(LocalDeclarationStatement( + VariableDeclaration(IdentifierName("var")) + .AddVariables( + VariableDeclarator(Identifier(decl.Name)) + .WithInitializer(EqualsValueClause(initExpr))))); + + stmts.Add(ExpressionStatement( + InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("G"), IdentifierName("Set")), + ArgumentList(SeparatedList(new[] + { + Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(decl.Name))), + Argument(IdentifierName(decl.Name)) + }))))); + } + else if (!string.IsNullOrEmpty(decl.InitialValueExpression)) + { + stmts.Add(LocalDeclarationStatement( + VariableDeclaration(IdentifierName("var")) + .AddVariables( + VariableDeclarator(Identifier(decl.Name)) + .WithInitializer( + EqualsValueClause(ParseExpression(decl.InitialValueExpression)))))); + + stmts.Add(ExpressionStatement( + InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("G"), IdentifierName("Set")), + ArgumentList(SeparatedList(new[] + { + Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(decl.Name))), + Argument(IdentifierName(decl.Name)) + }))))); + } + else + { + stmts.Add(LocalDeclarationStatement( + VariableDeclaration(ParseTypeName(decl.Type)) + .AddVariables(VariableDeclarator(Identifier(decl.Name))))); + + stmts.Add(ExpressionStatement( + InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("G"), IdentifierName("Set")), + ArgumentList(SeparatedList(new[] + { + Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(decl.Name))), + Argument(LiteralExpression(SyntaxKind.NullLiteralExpression)) + }))))); + } + + return stmts; + } + + /// + /// Formats a literal value as a Roslyn . + /// + internal static ExpressionSyntax FormatLiteralExpression(string type, object value) + { + return type switch + { + "string" => LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(value.ToString()!)), + "char" => LiteralExpression(SyntaxKind.CharacterLiteralExpression, Literal(char.Parse(value.ToString()!))), + "bool" => (bool)value + ? LiteralExpression(SyntaxKind.TrueLiteralExpression) + : LiteralExpression(SyntaxKind.FalseLiteralExpression), + "int" => LiteralExpression(SyntaxKind.NumericLiteralExpression, Literal((int)value)), + "long" => LiteralExpression(SyntaxKind.NumericLiteralExpression, Literal((long)value)), + "double" => LiteralExpression(SyntaxKind.NumericLiteralExpression, Literal((double)value)), + "float" => LiteralExpression(SyntaxKind.NumericLiteralExpression, Literal((float)value)), + _ => ParseExpression(value?.ToString() ?? "null") + }; + } + + // ────────────────────────────────────────────── + // Switch section generation + // ────────────────────────────────────────────── + + /// + /// Generates for each formatted block. + /// + internal static List GenerateSwitchSections( + FormattedBlockScript formattedScript, + Dictionary pubVarTypes, + List? helperFunctions) + { + var sections = new List(); + var helperReturnTypes = (helperFunctions ?? []) + .Where(h => h.Name != null) + .ToDictionary(h => h.Name!, h => h.ReturnType, StringComparer.Ordinal); + + foreach (var block in formattedScript.Blocks) + { + sections.Add(GenerateFormattedBlockCase(block, pubVarTypes, helperFunctions, helperReturnTypes)); + } + + sections.Add(SwitchSection() + .AddLabels(DefaultSwitchLabel()) + .AddStatements(ReturnStatement())); + + return sections; + } + + /// + /// Generates a single switch case from a . + /// + internal static SwitchSectionSyntax GenerateFormattedBlockCase( + FormattedBlock block, + Dictionary pubVarTypes, + List? helperFunctions, + Dictionary helperReturnTypes) + { + var caseStatements = new List(); + + caseStatements.Add(ExpressionStatement( + InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("G"), IdentifierName("ResetNextBlock"))))); + + caseStatements.Add(ExpressionStatement( + PostfixUnaryExpression(SyntaxKind.PostIncrementExpression, + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("G"), IdentifierName("ExecutedBlockCount"))))); + + var hasNextBlockAssignment = false; + + foreach (var stmt in block.Statements) + { + switch (stmt.Kind) + { + case FormattedStatementKind.Assignment: + case FormattedStatementKind.Expression: + { + ExpressionSyntax rawExpr; + string sourceType = "object"; + + if (stmt.FunctionName == "Get") + { + rawExpr = BuildGetInvocation(stmt.GetVarName ?? ""); + } + else if (IsHelperFunction(stmt.FunctionName, helperFunctions)) + { + var args = stmt.Arguments.Select(a => + Argument(ResolveArgumentExpression(a, pubVarTypes))).ToList(); + rawExpr = InvocationExpression(IdentifierName(stmt.FunctionName!), + ArgumentList(SeparatedList(args))); + sourceType = helperReturnTypes.TryGetValue(stmt.FunctionName ?? "", out var rt) + ? rt : "object"; + } + else if (stmt.FunctionName == "PluginCallWithTarget") + { + rawExpr = BuildPluginCallWithTargetExpression(stmt, pubVarTypes); + } + else if (stmt.FullFunctionName != null && stmt.FullFunctionName.Contains('.')) + { + rawExpr = BuildPluginCallExpression(stmt, pubVarTypes); + } + else + { + rawExpr = ParseExpression( + $"{stmt.FunctionName}({string.Join(", ", stmt.Arguments)})"); + } + + if (stmt.PubVarTarget != null) + { + var typeName = pubVarTypes.GetValueOrDefault(stmt.PubVarTarget, "object"); + ExpressionSyntax initExpr = (typeName != "object" && sourceType == "object") + ? BuildConvertToInvocation(typeName, rawExpr) + : rawExpr; + + caseStatements.Add(LocalDeclarationStatement( + VariableDeclaration(ParseTypeName(typeName)) + .AddVariables(VariableDeclarator(Identifier(stmt.PubVarTarget)) + .WithInitializer(EqualsValueClause(initExpr))))); + } + else + { + caseStatements.Add(ExpressionStatement(rawExpr)); + } + + break; + } + + case FormattedStatementKind.Branch: + { + var condExpr = !string.IsNullOrEmpty(stmt.ConditionPubVar) + ? ResolveArgumentExpression(stmt.ConditionPubVar, pubVarTypes) + : ParseExpression(stmt.ConditionExpression ?? "false"); + + caseStatements.Add(ExpressionStatement( + AssignmentExpression(SyntaxKind.SimpleAssignmentExpression, + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("G"), IdentifierName("NextBlock")), + InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("G"), IdentifierName("Branch")), + ArgumentList(SeparatedList(new[] + { + Argument(condExpr), + Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, + Literal(stmt.TrueBlockName ?? ""))), + Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, + Literal(stmt.FalseBlockName ?? ""))) + })))))); + + hasNextBlockAssignment = true; + caseStatements.Add(BreakStatement()); + break; + } + + case FormattedStatementKind.Loop: + { + var condExpr = !string.IsNullOrEmpty(stmt.ConditionPubVar) + ? ResolveArgumentExpression(stmt.ConditionPubVar, pubVarTypes) + : ParseExpression(stmt.ConditionExpression ?? "false"); + + caseStatements.Add(ExpressionStatement( + AssignmentExpression(SyntaxKind.SimpleAssignmentExpression, + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("G"), IdentifierName("NextBlock")), + InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("G"), IdentifierName("Loop")), + ArgumentList(SeparatedList(new[] + { + Argument(condExpr), + Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, + Literal(stmt.TrueBlockName ?? ""))), + Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, + Literal(stmt.FalseBlockName ?? ""))) + })))))); + + hasNextBlockAssignment = true; + caseStatements.Add(BreakStatement()); + break; + } + + case FormattedStatementKind.Print: + { + if (stmt.Arguments.Count > 0) + { + var argExpr = ResolveArgumentExpression(stmt.Arguments[0], pubVarTypes); + caseStatements.Add( + ExpressionStatement( + InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("G"), IdentifierName("Print")), + ArgumentList(SeparatedList(new[] { Argument(argExpr) }))))); + } + + break; + } + + case FormattedStatementKind.PluginCallWithTarget: + { + var args = new List(); + + if (stmt.Arguments.Count > 0) + args.Add(Argument(ResolveArgumentExpression(stmt.Arguments[0], pubVarTypes))); + else + args.Add(Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, Literal("")))); + + if (stmt.Arguments.Count > 1) + args.Add(Argument(ResolveArgumentExpression(stmt.Arguments[1], pubVarTypes))); + else + args.Add(Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, Literal("")))); + + if (stmt.Arguments.Count > 2) + args.Add(Argument(ResolveArgumentExpression(stmt.Arguments[2], pubVarTypes))); + else + args.Add(Argument(LiteralExpression(SyntaxKind.NullLiteralExpression))); + + for (int i = 3; i < stmt.Arguments.Count; i++) + args.Add(Argument(ResolveArgumentExpression(stmt.Arguments[i], pubVarTypes))); + + caseStatements.Add( + ExpressionStatement( + InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("G"), IdentifierName("PluginCallWithTarget")), + ArgumentList(SeparatedList(args))))); + + break; + } + + case FormattedStatementKind.TryGetDevice: + { + if (stmt.Arguments.Count > 0 && !string.IsNullOrEmpty(stmt.PubVarTarget)) + { + var patternExpr = ResolveArgumentExpression(stmt.Arguments[0], pubVarTypes); + caseStatements.Add( + LocalDeclarationStatement( + VariableDeclaration(IdentifierName("var")) + .AddVariables( + VariableDeclarator(Identifier(stmt.PubVarTarget)) + .WithInitializer( + EqualsValueClause( + InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("G"), IdentifierName("TryGetDevice")), + ArgumentList(SeparatedList(new[] { Argument(patternExpr) })))))))); + } + + break; + } + + case FormattedStatementKind.Set: + { + var varName = stmt.SetVarName ?? ""; + if (stmt.Arguments.Count > 0) + { + var valueExpr = ResolveArgumentExpression(stmt.Arguments[0], pubVarTypes); + caseStatements.Add( + ExpressionStatement( + InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("G"), IdentifierName("Set")), + ArgumentList(SeparatedList(new[] + { + Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(varName))), + Argument(valueExpr) + }))))); + } + + break; + } + + case FormattedStatementKind.ToLoopCond: + { + caseStatements.Add( + ExpressionStatement( + AssignmentExpression(SyntaxKind.SimpleAssignmentExpression, + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("G"), IdentifierName("NextBlock")), + InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("G"), IdentifierName("ToLoopCond")), + ArgumentList(SeparatedList(new[] + { + Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, + Literal(stmt.ToLoopCondReturnTo ?? ""))) + })))))); + + hasNextBlockAssignment = true; + caseStatements.Add(BreakStatement()); + break; + } + + case FormattedStatementKind.Break: + { + caseStatements.Add(ReturnStatement()); + break; + } + + case FormattedStatementKind.Pause: + { + if (stmt.Arguments.Count > 0) + { + var msExpr = ResolveArgumentExpression(stmt.Arguments[0], pubVarTypes); + caseStatements.Add( + ExpressionStatement( + InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("G"), IdentifierName("Pause")), + ArgumentList(SeparatedList(new[] { Argument(msExpr) }))))); + } + + break; + } + + default: + break; + } + } + + // Auto-complete NextBlock if block has NextBlockName and no explicit assignment + if (!hasNextBlockAssignment && !string.IsNullOrEmpty(block.NextBlockName)) + { + caseStatements.Add(ExpressionStatement( + AssignmentExpression(SyntaxKind.SimpleAssignmentExpression, + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("G"), IdentifierName("NextBlock")), + LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(block.NextBlockName))))); + } + + if (!caseStatements.Any(s => s is BreakStatementSyntax or ReturnStatementSyntax)) + caseStatements.Add(BreakStatement()); + + return SwitchSection() + .AddLabels(CaseSwitchLabel( + LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(block.Name)))) + .AddStatements(caseStatements.ToArray()); + } + + // ────────────────────────────────────────────── + // Expression builders + // ────────────────────────────────────────────── + + /// + /// Builds ConvertTo<T>(arg) expression. + /// + internal static InvocationExpressionSyntax BuildConvertToInvocation(string typeName, ExpressionSyntax argExpr) + { + return InvocationExpression( + GenericName(Identifier("ConvertTo"), + TypeArgumentList(SeparatedList(new TypeSyntax[] { ParseTypeName(typeName) }))), + ArgumentList(SeparatedList(new[] { Argument(argExpr) }))); + } + + /// + /// Builds G.Get<object>("varName") expression. + /// + internal static InvocationExpressionSyntax BuildGetInvocation(string varName) + { + return InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("G"), + GenericName(Identifier("Get"), + TypeArgumentList(SeparatedList(new TypeSyntax[] + { PredefinedType(Token(SyntaxKind.ObjectKeyword)) })))), + ArgumentList(SeparatedList(new[] + { + Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(varName))) + }))); + } + + /// + /// Builds G.PluginCall("pluginName", "methodName", args...) expression. + /// + internal static InvocationExpressionSyntax BuildPluginCallExpression( + FormattedStatement stmt, Dictionary pubVarTypes) + { + var lastDot = (stmt.FullFunctionName ?? "").LastIndexOf('.'); + var pluginName = lastDot >= 0 ? stmt.FullFunctionName![..lastDot] : stmt.FullFunctionName ?? ""; + var methodName = lastDot >= 0 ? stmt.FullFunctionName![(lastDot + 1)..] : ""; + + var pluginCallArgs = new List + { + Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(pluginName))), + Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(methodName))) + }; + pluginCallArgs.AddRange(stmt.Arguments.Select(a => + Argument(ResolveArgumentExpression(a, pubVarTypes)))); + + return InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("G"), IdentifierName("PluginCall")), + ArgumentList(SeparatedList(pluginCallArgs))); + } + + /// + /// Builds G.PluginCallWithTarget("pluginName", "methodName", "targetDevice", args...) expression. + /// + internal static InvocationExpressionSyntax BuildPluginCallWithTargetExpression( + FormattedStatement stmt, Dictionary pubVarTypes) + { + var pluginNameArg = stmt.Arguments.Count > 0 ? stmt.Arguments[0] : "\"\""; + var methodNameArg = stmt.Arguments.Count > 1 ? stmt.Arguments[1] : "\"\""; + var targetDeviceArg = stmt.Arguments.Count > 2 ? stmt.Arguments[2] : "\"\""; + var callArgs = stmt.Arguments.Count > 3 + ? stmt.Arguments.Skip(3).ToList() + : new List(); + + var args = new List + { + Argument(ResolveArgumentExpression(pluginNameArg, pubVarTypes)), + Argument(ResolveArgumentExpression(methodNameArg, pubVarTypes)), + Argument(ResolveArgumentExpression(targetDeviceArg, pubVarTypes)) + }; + args.AddRange(callArgs.Select(a => + Argument(ResolveArgumentExpression(a, pubVarTypes)))); + + return InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("G"), IdentifierName("PluginCallWithTarget")), + ArgumentList(SeparatedList(args))); + } + + /// + /// Resolves a formatted argument string into a Roslyn . + /// + internal static ExpressionSyntax ResolveArgumentExpression( + string arg, Dictionary pubVarTypes) + { + arg = arg.Trim(); + + if (pubVarTypes.ContainsKey(arg)) + return IdentifierName(arg); + + var parsed = ParseExpression(arg); + return parsed ?? IdentifierName(arg); + } + + /// + /// Checks whether a function name corresponds to a registered HelperFunction. + /// + internal static bool IsHelperFunction(string? name, List? helperFunctions) + { + if (name == null || helperFunctions == null) return false; + return helperFunctions.Any(h => h.Name == name); + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptCompilationBackend.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptCompilationBackend.cs new file mode 100644 index 00000000..7579b4e6 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptCompilationBackend.cs @@ -0,0 +1,137 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.Loader; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using KitX.Core.Contract.Workflow; +using Serilog; + +namespace KitX.Core.Workflow.BlockScripting; + +/// +/// Roslyn compilation backend — compiles +/// into a .NET assembly using . +/// +internal static class ScriptCompilationBackend +{ + /// + /// Compiles the generated into a .NET assembly. + /// + /// The syntax tree to compile. + /// Script hash used for naming. + /// A memory stream containing the assembly, or null on failure. + internal static MemoryStream? CompileToAssembly(CompilationUnitSyntax compilationUnit, string hash) + { + var normalized = compilationUnit.NormalizeWhitespace(); + var sourceText = normalized.ToFullString(); + + Log.Debug("[ScriptCompilationBackend] Generated source code for hash '{Hash}':\n{Source}", hash, sourceText); + + var syntaxTree = CSharpSyntaxTree.Create(normalized, + path: $"CompiledScript_{hash}.cs"); + + var compilation = CSharpCompilation.Create( + $"CompiledScript_{hash}", + syntaxTrees: new[] { syntaxTree }, + references: GetCompilationReferences(), + options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + .WithOptimizationLevel(OptimizationLevel.Release) + .WithAssemblyIdentityComparer(DesktopAssemblyIdentityComparer.Default)); + + var assemblyStream = new MemoryStream(); + var emitResult = compilation.Emit(assemblyStream); + + if (!emitResult.Success) + { + var diagnostics = emitResult.Diagnostics + .Where(d => d.Severity >= DiagnosticSeverity.Error) + .Select(d => d.ToString()) + .ToList(); + + Log.Warning("[ScriptCompilationBackend] Compilation failed with {ErrorCount} errors:", + diagnostics.Count); + foreach (var diag in diagnostics.Take(10)) + { + Log.Warning("[ScriptCompilationBackend] {Diagnostic}", diag); + } + + return null; + } + + assemblyStream.Position = 0; + Log.Debug("[ScriptCompilationBackend] Assembly compiled successfully: {Size} bytes", + assemblyStream.Length); + return assemblyStream; + } + + /// + /// Gets the set of s needed for compilation. + /// + internal static List GetCompilationReferences() + { + var references = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + var seedAssemblies = new Assembly[] + { + typeof(BlockScriptExecutionGlobals).Assembly, + typeof(KitX.Core.Contract.Workflow.BlockScript).Assembly, + typeof(ICompiledBlockScript).Assembly, + typeof(KitX.Core.Contract.Workflow.PluginCallInfo).Assembly, + typeof(Microsoft.CSharp.RuntimeBinder.Binder).Assembly, + typeof(object).Assembly, + typeof(System.Collections.Generic.List<>).Assembly, + typeof(System.Linq.Enumerable).Assembly, + }; + + foreach (var assembly in seedAssemblies) + { + if (string.IsNullOrEmpty(assembly.Location)) continue; + if (!seen.Add(assembly.Location)) continue; + references.Add(MetadataReference.CreateFromFile(assembly.Location)); + } + + foreach (var assembly in AssemblyLoadContext.Default.Assemblies) + { + if (assembly.IsDynamic || string.IsNullOrEmpty(assembly.Location)) continue; + if (!seen.Add(assembly.Location)) continue; + + references.Add(MetadataReference.CreateFromFile(assembly.Location)); + } + + Log.Debug("[ScriptCompilationBackend] Compilation references: {Count} assemblies", references.Count); + return references; + } + + /// + /// Computes a deterministic hash for a BlockScript based on its structural content. + /// + internal static string ComputeScriptHash(BlockScript script) + { + var hashInput = new System.Text.StringBuilder(); + + foreach (var block in script.AllBlocks) + { + hashInput.Append($"[{block.Name}:{block.Type}:{block.NextBlockName}]"); + foreach (var stmt in block.Statements) + { + hashInput.Append($"<{stmt.SourceCode}>"); + } + } + + foreach (var helper in script.HelperFunctions ?? []) + { + hashInput.Append($"{{H:{helper.Name}:{helper.Code}}}"); + } + + var hash = 0; + foreach (var c in hashInput.ToString()) + hash = (hash * 31 + c) & 0x7FFFFFFF; + + return hash.ToString("x8"); + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptPersistenceManager.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptPersistenceManager.cs new file mode 100644 index 00000000..8148ab8f --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptPersistenceManager.cs @@ -0,0 +1,224 @@ +using System; +using System.IO; +using System.Text.Json; +using KitX.Core.Contract.Workflow; +using Serilog; + +namespace KitX.Core.Workflow.BlockScripting; + +/// +/// Manages disk persistence of compiled script assemblies. +/// Handles loading, saving, and preloading of compiled scripts from disk. +/// +internal class ScriptPersistenceManager +{ + /// + /// Root directory for persisted compiled script assemblies. + /// Each workflow gets a subdirectory: Data/CompiledScripts/{workflow-id}/ + /// + internal static readonly string CompiledScriptsRoot = Path.Combine("./Data/", "CompiledScripts"); + + /// + /// JSON serializer options for meta.json persistence. + /// + internal static readonly JsonSerializerOptions MetaJsonOptions = new() + { + WriteIndented = true, + PropertyNameCaseInsensitive = true, + }; + + /// + /// Callback to register a loaded compiled script into the cache. + /// + private readonly Action _registerCacheEntry; + + /// + /// Callback to compute the current KitX version for cache invalidation. + /// + private readonly Func _getKitXVersion; + + /// + /// The cache dictionary for registering entries. + /// + private readonly Func _tryGetCacheEntry; + + /// + /// Initializes a new persistence manager. + /// + /// Called to register a loaded entry into the cache. + /// Called to get the current KitX version. + /// Called to check if an entry is already cached. + internal ScriptPersistenceManager( + Action registerCacheEntry, + Func getKitXVersion, + Func tryGetCacheEntry) + { + _registerCacheEntry = registerCacheEntry; + _getKitXVersion = getKitXVersion; + _tryGetCacheEntry = tryGetCacheEntry; + } + + /// + /// Attempts to load a compiled script assembly from disk. + /// Validates KitX version match before loading; stale assemblies are deleted. + /// + /// Loaded instance, or null if not found / stale / corrupt. + internal ICompiledBlockScript? TryLoadFromDisk(string workflowId, string hash) + { + var dir = Path.Combine(CompiledScriptsRoot, workflowId); + var dllPath = Path.Combine(dir, $"{hash}.dll"); + var metaPath = Path.Combine(dir, $"{hash}.meta.json"); + + if (!File.Exists(dllPath) || !File.Exists(metaPath)) + return null; + + try + { + var metaJson = File.ReadAllText(metaPath); + var meta = JsonSerializer.Deserialize(metaJson, MetaJsonOptions); + if (meta == null) + { + Log.Debug("[ScriptPersistenceManager] Corrupt meta.json for hash '{Hash}', deleting", hash); + DeleteFromDisk(workflowId, hash); + return null; + } + + var currentVersion = _getKitXVersion(); + if (meta.KitXVersion != currentVersion) + { + Log.Debug("[ScriptPersistenceManager] KitX version mismatch for hash '{Hash}': " + + "disk={DiskVer}, current={CurrentVer}. Deleting and recompiling.", + hash, meta.KitXVersion, currentVersion); + DeleteFromDisk(workflowId, hash); + return null; + } + + if (string.IsNullOrEmpty(meta.TypeName)) + { + Log.Debug("[ScriptPersistenceManager] Missing TypeName in meta for hash '{Hash}', deleting", hash); + DeleteFromDisk(workflowId, hash); + return null; + } + + var dllBytes = File.ReadAllBytes(dllPath); + var alc = new CollectibleAssemblyLoadContext(hash); + var loadedAssembly = alc.LoadFromStream(new MemoryStream(dllBytes)); + + var scriptType = loadedAssembly.GetType(meta.TypeName); + if (scriptType == null) + { + Log.Debug("[ScriptPersistenceManager] Type '{TypeName}' not found in disk assembly for hash '{Hash}', deleting", + meta.TypeName, hash); + alc.Unload(); + DeleteFromDisk(workflowId, hash); + return null; + } + + var instance = (ICompiledBlockScript)Activator.CreateInstance(scriptType)!; + var entry = new CompiledScriptEntry(instance, alc); + _registerCacheEntry(hash, entry); + + return instance; + } + catch (Exception ex) + { + Log.Debug(ex, "[ScriptPersistenceManager] Error loading from disk for hash '{Hash}', deleting", hash); + DeleteFromDisk(workflowId, hash); + return null; + } + } + + /// + /// Persists a compiled assembly and its metadata to disk. + /// + internal void SaveToDisk(string workflowId, string hash, MemoryStream assemblyBytes, string typeName) + { + try + { + var dir = Path.Combine(CompiledScriptsRoot, workflowId); + Directory.CreateDirectory(dir); + + var dllPath = Path.Combine(dir, $"{hash}.dll"); + var metaPath = Path.Combine(dir, $"{hash}.meta.json"); + + File.WriteAllBytes(dllPath, assemblyBytes.ToArray()); + + var meta = new CompiledScriptMeta + { + ScriptHash = hash, + CompileTimeUtc = DateTime.UtcNow, + KitXVersion = _getKitXVersion(), + TypeName = typeName + }; + var metaJson = JsonSerializer.Serialize(meta, MetaJsonOptions); + File.WriteAllText(metaPath, metaJson); + + Log.Debug("[ScriptPersistenceManager] Persisted compiled script hash '{Hash}' to disk (workflow: {WfId})", + hash, workflowId); + } + catch (Exception ex) + { + Log.Debug(ex, "[ScriptPersistenceManager] Failed to persist compiled script to disk for hash '{Hash}'", hash); + } + } + + /// + /// Deletes persisted assembly files from disk. + /// + internal static void DeleteFromDisk(string workflowId, string hash) + { + try + { + var dir = Path.Combine(CompiledScriptsRoot, workflowId); + var dllPath = Path.Combine(dir, $"{hash}.dll"); + var metaPath = Path.Combine(dir, $"{hash}.meta.json"); + + if (File.Exists(dllPath)) File.Delete(dllPath); + if (File.Exists(metaPath)) File.Delete(metaPath); + } + catch (Exception ex) + { + Log.Debug(ex, "[ScriptPersistenceManager] Error deleting disk cache for hash '{Hash}'", hash); + } + } + + /// + /// Preloads all persisted compiled scripts for a given workflow from disk. + /// + /// Workflow ID to preload scripts for. + /// Number of scripts successfully loaded into cache. + internal int PreloadFromDisk(string workflowId) + { + var dir = Path.Combine(CompiledScriptsRoot, workflowId); + if (!Directory.Exists(dir)) + return 0; + + var count = 0; + foreach (var metaPath in Directory.GetFiles(dir, "*.meta.json")) + { + try + { + var metaJson = File.ReadAllText(metaPath); + var meta = JsonSerializer.Deserialize(metaJson, MetaJsonOptions); + if (meta == null || string.IsNullOrEmpty(meta.ScriptHash)) + continue; + + if (_tryGetCacheEntry(meta.ScriptHash) != null) + continue; + + if (TryLoadFromDisk(workflowId, meta.ScriptHash) != null) + count++; + } + catch (Exception ex) + { + Log.Debug(ex, "[ScriptPersistenceManager] Error preloading from {Path}", metaPath); + } + } + + if (count > 0) + Log.Debug("[ScriptPersistenceManager] Preloaded {Count} compiled scripts for workflow {WfId}", + count, workflowId); + + return count; + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/INodeExportStrategy.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/INodeExportStrategy.cs deleted file mode 100644 index 7e9b628e..00000000 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/INodeExportStrategy.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System.Collections.Generic; -using KitX.Core.Contract.Workflow; - -namespace KitX.Core.Workflow.Blueprint; - -/// -/// Helper service providing data resolution utilities for node export strategies. -/// Implemented by BlueprintToBlockScriptConverter. -/// -public interface INodeExportHelper -{ - /// - /// Resolves the input value for a pin by tracing data connections. - /// Returns ConstName for const references, PubVarName for pubvar references, - /// or the pin's default value. - /// - string GetInputValue(BlueprintNode node, string pinName); - - /// - /// Resolves all non-Exec input arguments for a node, returning them as a comma-separated string. - /// - string GetInputArgs(BlueprintNode node); - - /// - /// The blueprint being converted. - /// - Contract.Workflow.Blueprint Blueprint { get; } -} - -/// -/// Strategy for converting a specific node type to a BlockScript statement. -/// Each node type that participates in reverse conversion provides an implementation, -/// eliminating the need for switch-based dispatch in the converter. -/// -public interface INodeExportStrategy -{ - /// - /// The node type this strategy handles. - /// - BlueprintNodeType NodeType { get; } - - /// - /// Whether this node type represents a control flow construct (Branch, Loop, etc.). - /// Used by the converter to determine main flow termination and sub-graph processing. - /// - bool IsControlFlow { get; } - - /// - /// Converts the node to a BlockScript statement, or null if the node should be skipped. - /// - BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper); - - /// - /// For control flow nodes: returns the output arm configuration. - /// Each arm defines an output pin name and whether it represents a loopback. - /// Non-control-flow strategies return an empty collection. - /// - IEnumerable GetOutputArms(BlueprintNode node); -} - -/// -/// Describes a single output arm of a control flow node (e.g., Branch's True/False, Loop's LoopBody/LoopEnd). -/// -public struct OutputArmDescriptor -{ - /// - /// The output pin name (e.g., Pins.True, Pins.False, Pins.LoopBody, Pins.LoopEnd) - /// - public string PinName { get; set; } - - /// - /// Whether this arm loops back to a parent node (LoopBody loops back to the Loop node) - /// - public bool IsLoopback { get; set; } -} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/ConstantValueRewriter.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/ConstantValueRewriter.cs new file mode 100644 index 00000000..fcbcb9a1 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/ConstantValueRewriter.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace KitX.Core.Workflow; + +/// +/// Rewriter for injecting constant values into variable declarations using CSharpSyntaxRewriter +/// +/// +/// This approach is more reliable than regex-based matching as it understands the actual +/// syntax structure and won't accidentally match content in comments or strings. +/// +internal class ConstantValueRewriter : CSharpSyntaxRewriter +{ + private readonly Dictionary _constantValues; + + public ConstantValueRewriter(Dictionary constantValues) + { + _constantValues = constantValues; + } + + /// + /// Visits variable declarators to replace their initializer values + /// + public override SyntaxNode? VisitVariableDeclarator(VariableDeclaratorSyntax node) + { + // Check if this variable declarator has an initializer and matches a constant name + if (node.Initializer != null && _constantValues.TryGetValue(node.Identifier.Text, out var newValue)) + { + var newInitializer = CreateNewInitializer(node.Initializer, newValue); + if (newInitializer != null) + { + return node.WithInitializer(newInitializer); + } + } + + return base.VisitVariableDeclarator(node); + } + + /// + /// Creates a new EqualsValueClauseSyntax with the specified value + /// + private EqualsValueClauseSyntax? CreateNewInitializer(EqualsValueClauseSyntax oldInitializer, object? value) + { + ExpressionSyntax? newExpression = null; + + if (value == null) + { + newExpression = SyntaxFactory.LiteralExpression(SyntaxKind.NullLiteralExpression); + } + else if (value is int intVal) + { + newExpression = SyntaxFactory.LiteralExpression( + SyntaxKind.NumericLiteralExpression, + SyntaxFactory.Literal(intVal)); + } + else if (value is long longVal) + { + newExpression = SyntaxFactory.LiteralExpression( + SyntaxKind.NumericLiteralExpression, + SyntaxFactory.Literal(longVal)); + } + else if (value is double doubleVal) + { + newExpression = SyntaxFactory.LiteralExpression( + SyntaxKind.NumericLiteralExpression, + SyntaxFactory.Literal(doubleVal)); + } + else if (value is float floatVal) + { + newExpression = SyntaxFactory.LiteralExpression( + SyntaxKind.NumericLiteralExpression, + SyntaxFactory.Literal(floatVal)); + } + else if (value is decimal decimalVal) + { + newExpression = SyntaxFactory.LiteralExpression( + SyntaxKind.NumericLiteralExpression, + SyntaxFactory.Literal(decimalVal)); + } + else if (value is bool boolVal) + { + newExpression = SyntaxFactory.LiteralExpression( + boolVal ? SyntaxKind.TrueLiteralExpression : SyntaxKind.FalseLiteralExpression); + } + else if (value is string stringVal) + { + newExpression = SyntaxFactory.LiteralExpression( + SyntaxKind.StringLiteralExpression, + SyntaxFactory.Literal(stringVal)); + } + else if (value is char charVal) + { + newExpression = SyntaxFactory.LiteralExpression( + SyntaxKind.CharacterLiteralExpression, + SyntaxFactory.Literal(charVal)); + } + + if (newExpression != null) + { + return SyntaxFactory.EqualsValueClause(newExpression) + .WithLeadingTrivia(oldInitializer.GetLeadingTrivia()) + .WithTrailingTrivia(oldInitializer.GetTrailingTrivia()); + } + + return null; + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/RealPluginManager.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/RealPluginManager.cs index 57e54ab7..d9b1a2f8 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/RealPluginManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/RealPluginManager.cs @@ -8,7 +8,10 @@ using System.Threading.Tasks; using KitX.Core.Contract.Workflow; using KitX.Core.Contract.Plugin; +using KitX.Core.Contract.Device; using KitX.Core.Device; +using KitX.Core.Contract.Plugin.Events; +using KitX.Core.Device.Events; using KitX.Shared.CSharp.Device; using KitX.Shared.CSharp.Plugin; using KitX.Shared.CSharp.WebCommand; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/ScriptExecutionService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/ScriptExecutionService.cs new file mode 100644 index 00000000..be610a14 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/ScriptExecutionService.cs @@ -0,0 +1,319 @@ +using System; +using System.Diagnostics; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Csharpell.Core; +using KitX.Core.Contract.Workflow; +using KitX.Shared.CSharp.Plugin; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Scripting; +using Serilog; + +namespace KitX.Core.Workflow; + +/// +/// C# script execution service. +/// Implements IScriptExecutionService. +/// +internal class ScriptExecutionService : IScriptExecutionService +{ + private readonly WorkflowRuntimeState _state; + private readonly IWorkflowPluginService _pluginService; + + /// + /// Initializes a new instance of ScriptExecutionService. + /// + /// Shared runtime state. + /// Plugin service for constants and helper functions. + internal ScriptExecutionService(WorkflowRuntimeState state, IWorkflowPluginService pluginService) + { + _state = state; + _pluginService = pluginService; + } + + /// + /// Gets the script engine, creating it if necessary. + /// + private CSharpScriptEngine Engine => _state.Engine ??= new CSharpScriptEngine(); + + /// + public async Task ExecuteScriptAsync(string script, System.Collections.Generic.Dictionary? parameters = null) + { + const string location = $"{nameof(ScriptExecutionService)}.{nameof(ExecuteScriptAsync)}"; + + try + { + if (string.IsNullOrWhiteSpace(script)) + { + Log.Warning("Script is empty"); + return await System.Threading.Tasks.Task.FromResult(null); + } + + var result = await ExecuteCodesAsync(script, null, true, CancellationToken.None); + return await System.Threading.Tasks.Task.FromResult(result); + } + catch (Exception ex) + { + Log.Error(ex, $"In {location}: Error executing script: {ex.Message}"); + return await System.Threading.Tasks.Task.FromResult(null); + } + } + + /// + public async Task ExecuteCodesAsync( + string code, + System.Collections.Generic.List? requiredPlugins = null, + bool includeTimestamp = true, + CancellationToken cancellationToken = default) + { + const string location = $"{nameof(ScriptExecutionService)}.{nameof(ExecuteCodesAsync)}"; + + var sw = new Stopwatch(); + var begin = DateTime.Now; + sw.Start(); + + try + { + if (requiredPlugins != null && requiredPlugins.Any()) + { + if (!_state.IsParserInitialized) + { + _pluginService.InitializePluginManager(); + } + + var pluginsReady = await EnsurePluginsReadyAsync(requiredPlugins, cancellationToken); + + if (!pluginsReady) + { + Log.Warning("[ScriptExecutionService] Some plugins failed to start, but continuing script execution"); + } + + Assembly? pluginApiAssembly = null; + try + { + pluginApiAssembly = Kscript.CSharp.Parser.Parser.Generate(requiredPlugins, "KitXWorkflowPlugins", useCache: false); + Log.Information($"[ScriptExecutionService] Successfully generated plugin API with {requiredPlugins.Count} plugins"); + } + catch (Exception ex) + { + var error = $"Failed to generate plugin API: {ex.Message}"; + Log.Error(ex, error); + } + + var result = await ExecuteScriptWithPluginsAsync( + code, + pluginApiAssembly, + includeTimestamp, + cancellationToken + ); + + sw.Stop(); + return FormatExecutionResult(result, begin, sw.ElapsedMilliseconds, includeTimestamp); + } + else + { + return await ExecuteCodesWithoutPluginsAsync(code, includeTimestamp, cancellationToken); + } + } + catch (Exception ex) + { + sw.Stop(); + Log.Error(ex, $"In {location}: Error executing code: {ex.Message}"); + return FormatExecutionError(ex, begin, sw.ElapsedMilliseconds, includeTimestamp); + } + } + + /// + public async Task ExecuteKcsCodesAsync( + string mainCode, + System.Collections.Generic.List helperFunctions, + System.Collections.Generic.List constants, + System.Collections.Generic.List? requiredPlugins = null, + bool includeTimestamp = true, + CancellationToken cancellationToken = default) + { + // 1. Analyze main program + var analyzer = new MainProgramAnalyzer(); + var analysisResult = analyzer.Analyze(mainCode); + + if (!analysisResult.IsValid) + { + return includeTimestamp + ? $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [E] Code analysis failed: {analysisResult.ForbiddenReason}" + : $"Code analysis failed: {analysisResult.ForbiddenReason}"; + } + + // 2. Apply constants + var codeWithConstants = _pluginService.ApplyConstantsToCode(mainCode, constants); + + // 3. Merge helper functions + var fullCode = _pluginService.MergeHelperFunctions(codeWithConstants, helperFunctions); + + // 4. Execute + return await ExecuteCodesAsync(fullCode, requiredPlugins, includeTimestamp, cancellationToken); + } + + /// + /// Executes script with plugin dependencies. + /// + private async Task ExecuteScriptWithPluginsAsync( + string code, + Assembly? pluginApiAssembly, + bool includeTimestamp, + CancellationToken cancellationToken) + { + try + { + WorkflowOutput.GetAndClear(); + + var result = await Engine.ExecuteAsync( + code, + options => + { + options = options + .WithReferences(Assembly.GetExecutingAssembly()) + .WithImports( + "KitX", + "KitX.Core", + "KitX.Core.Workflow", + "KitX.Shared.CSharp.Plugin", + "System", + "System.Collections.Generic", + "System.Threading.Tasks" + ) + .WithLanguageVersion(LanguageVersion.Preview); + + if (pluginApiAssembly != null) + { + options = options.WithReferences(pluginApiAssembly); + } + + return options; + }, + addDefaultImports: true, + runInReplMode: false, + cancellationToken: cancellationToken + ); + + var scriptOutput = WorkflowOutput.GetAndClear(); + var returnValue = result?.ToString(); + + return CombineOutput(scriptOutput, returnValue); + } + catch (Exception ex) + { + Log.Error(ex, "[ScriptExecutionService] Error executing script with plugins"); + return $"Script execution error: {ex.Message}"; + } + } + + /// + /// Ensures required plugins are loaded and running. + /// + private Task EnsurePluginsReadyAsync( + System.Collections.Generic.List requiredPlugins, + CancellationToken cancellationToken = default) + { + Log.Information($"[ScriptExecutionService] Checking {requiredPlugins.Count} required plugins"); + + foreach (var plugin in requiredPlugins) + { + Log.Information($"[ScriptExecutionService] Required plugin: {plugin.Name}"); + } + + return System.Threading.Tasks.Task.FromResult(true); + } + + /// + /// Executes code without plugin dependencies. + /// + private async Task ExecuteCodesWithoutPluginsAsync( + string code, + bool includeTimestamp, + CancellationToken cancellationToken) + { + var sw = new Stopwatch(); + var begin = DateTime.Now; + sw.Start(); + + try + { + WorkflowOutput.GetAndClear(); + + var result = await Engine.ExecuteAsync( + code, + options => options + .WithReferences(Assembly.GetExecutingAssembly()) + .WithImports( + "System", + "System.Collections.Generic", + "System.Threading.Tasks", + "KitX.Core.Workflow" + ) + .WithLanguageVersion(LanguageVersion.Preview), + addDefaultImports: true, + runInReplMode: false, + cancellationToken: cancellationToken + ); + + sw.Stop(); + + var scriptOutput = WorkflowOutput.GetAndClear(); + var returnValue = result?.ToString(); + + var combinedOutput = CombineOutput(scriptOutput, returnValue); + + return FormatExecutionResult(combinedOutput, begin, sw.ElapsedMilliseconds, includeTimestamp); + } + catch (Exception ex) + { + sw.Stop(); + return FormatExecutionError(ex, begin, sw.ElapsedMilliseconds, includeTimestamp); + } + } + + /// + /// Formats successful execution output with optional timestamp header. + /// + private static string? FormatExecutionResult(string? output, DateTime begin, long elapsedMs, bool includeTimestamp) + { + if (!includeTimestamp) + return output; + + return new StringBuilder() + .AppendLine($"[{begin:yyyy-MM-dd HH:mm:ss}] [I] Workflow script posted.") + .AppendLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [I] Script ended, took {elapsedMs} ms.") + .AppendLine(output) + .ToString(); + } + + /// + /// Formats execution error with optional timestamp header. + /// + private static string? FormatExecutionError(Exception ex, DateTime begin, long elapsedMs, bool includeTimestamp) + { + if (!includeTimestamp) + return ex.StackTrace; + + return new StringBuilder() + .AppendLine($"[{begin:yyyy-MM-dd HH:mm:ss}] [I] Workflow script posted.") + .AppendLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [E] Exception caught after {elapsedMs} ms, Message: {ex.Message}") + .AppendLine(ex.StackTrace) + .ToString(); + } + + /// + /// Combines WorkflowOutput and script return value into a single output string. + /// + private static string? CombineOutput(string? scriptOutput, string? returnValue) + { + return !string.IsNullOrEmpty(scriptOutput) + ? (string.IsNullOrEmpty(returnValue) + ? scriptOutput + : $"{scriptOutput}\n{returnValue}") + : returnValue; + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/TriggerManager.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/TriggerManager.cs index fb1b422e..f20765b0 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/TriggerManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/TriggerManager.cs @@ -3,7 +3,9 @@ using System.Linq; using System.Text.Json; using KitX.Core.Contract.Workflow; +using KitX.Core.Contract.Device; using KitX.Core.Device; +using KitX.Core.Device.Events; using KitX.Shared.CSharp.WebCommand; using KitX.Shared.CSharp.WebCommand.Infos; using Serilog; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowCase.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowCase.cs new file mode 100644 index 00000000..9fc26010 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowCase.cs @@ -0,0 +1,34 @@ +using System; +using KitX.Core.Contract.Workflow; + +namespace KitX.Core.Workflow; + +/// +/// Workflow case implementation +/// +public class WorkflowCase : IWorkflowCase +{ + public string Id { get; set; } = Guid.NewGuid().ToString(); + + public string Name { get; set; } = "Untitled Workflow"; + + public string Description { get; set; } = string.Empty; + + public string Author { get; set; } = string.Empty; + + public bool IsRunning { get; set; } + + public bool IsError { get; set; } + + public string? ErrorMessage { get; set; } + + public string? ScriptPath { get; set; } + + public DateTime CreatedTime { get; set; } = DateTime.UtcNow; + + public DateTime LastModifiedTime { get; set; } = DateTime.UtcNow; + + public string TriggerType { get; set; } = "Manual"; + + public TriggerConfig? TriggerConfig { get; set; } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowManagementService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowManagementService.cs new file mode 100644 index 00000000..043f94ee --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowManagementService.cs @@ -0,0 +1,223 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using KitX.Core.Contract.Workflow; +using KitX.Shared.CSharp.Plugin; +using Serilog; + +namespace KitX.Core.Workflow; + +/// +/// Workflow lifecycle management service. +/// Implements IWorkflowManagementService. +/// +internal class WorkflowManagementService : IWorkflowManagementService +{ + private readonly WorkflowRuntimeState _state; + private readonly IBlockScriptService _blockScriptService; + + /// + /// Initializes a new instance of WorkflowManagementService. + /// + /// Shared runtime state. + /// Block script execution service. + internal WorkflowManagementService(WorkflowRuntimeState state, IBlockScriptService blockScriptService) + { + _state = state; + _blockScriptService = blockScriptService; + } + + /// + public IReadOnlyList GetWorkflows() + { + return _state.Workflows.ToList(); + } + + /// + public void AddWorkflow(IWorkflowCase workflow) + { + if (workflow != null && !_state.Workflows.Any(w => w.Id == workflow.Id)) + { + _state.Workflows.Add(workflow); + } + } + + /// + public void RemoveWorkflow(string workflowId) + { + var workflow = _state.Workflows.FirstOrDefault(w => w.Id == workflowId); + if (workflow != null) + { + _state.Workflows.Remove(workflow); + } + } + + /// + public async Task RunWorkflowAsync(string workflowId) + { + const string location = $"{nameof(WorkflowManagementService)}.{nameof(RunWorkflowAsync)}"; + + try + { + var storageService = WorkflowStorageService.Instance; + var data = await storageService.LoadWorkflowDataAsync(workflowId); + + if (data == null) + { + Log.Warning("[{Location}] Workflow data not found in storage for ID: {WorkflowId}. " + + "Expected file path: {Path}", location, workflowId, + storageService.GetWorkflowFilePath(workflowId)); + return false; + } + + Log.Information("[{Location}] Loaded workflow '{Name}' (ID: {Id}), " + + "UseBlockMode: {UseBlockMode}, BlockScriptSource length: {BsLen}, " + + "MainProgram length: {MpLen}, Helpers: {HelperCount}", + location, data.Name, workflowId, data.UseBlockMode, + data.BlockScriptSource?.Length ?? 0, + data.MainProgram?.Length ?? 0, + data.HelperFunctions?.Count ?? 0); + + string sourceCode; + List? helpers = data.HelperFunctions; + + if (data.UseBlockMode && !string.IsNullOrWhiteSpace(data.BlockScriptSource)) + { + sourceCode = data.BlockScriptSource; + } + else if (!string.IsNullOrWhiteSpace(data.MainProgram)) + { + sourceCode = data.MainProgram; + } + else + { + Log.Warning("[{Location}] Workflow '{Name}' (ID: {Id}) has no executable source code", + location, data.Name, workflowId); + return false; + } + + Log.Information("[{Location}] Executing workflow '{Name}' ({SourceLength} chars)...", + location, data.Name, sourceCode.Length); + + var result = await _blockScriptService.ExecuteBlockScriptAsync( + sourceCode, + helpers ?? new List(), + CancellationToken.None); + + if (result.IsSuccess) + { + var output = result.Output != null && result.Output.Count > 0 + ? string.Join("\n", result.Output) + : "(no output)"; + Log.Information("[{Location}] Workflow '{Name}' executed successfully. " + + "Blocks: {Blocks}, Time: {Time}ms\nOutput:\n{Output}", + location, data.Name, result.ExecutedBlockCount, result.ExecutionTimeMs, output); + } + else + { + Log.Error("[{Location}] Workflow '{Name}' execution failed: {Error}", + location, data.Name, result.ErrorMessage); + } + + return result.IsSuccess; + } + catch (Exception ex) + { + Log.Error(ex, "[{Location}] Error running workflow {WorkflowId}: {Message}", + location, workflowId, ex.Message); + return false; + } + } + + /// + public async Task StopWorkflowAsync(string workflowId) + { + const string location = $"{nameof(WorkflowManagementService)}.{nameof(StopWorkflowAsync)}"; + + try + { + var storageService = WorkflowStorageService.Instance; + var data = await storageService.LoadWorkflowDataAsync(workflowId); + + if (data == null) + { + Log.Warning("[{Location}] Workflow data not found in storage for ID: {WorkflowId}", + location, workflowId); + return false; + } + + Log.Information("[{Location}] Stop requested for workflow '{Name}' (ID: {WorkflowId}) — " + + "cancellation not yet implemented, workflow will complete current execution", + location, data.Name, workflowId); + + return await Task.FromResult(true); + } + catch (Exception ex) + { + Log.Error(ex, "[{Location}] Error stopping workflow {WorkflowId}: {Message}", + location, workflowId, ex.Message); + return false; + } + } + + /// + public async Task CompileAndPersistWorkflowAsync(string workflowId) + { + const string location = $"{nameof(WorkflowManagementService)}.{nameof(CompileAndPersistWorkflowAsync)}"; + + try + { + var storageService = WorkflowStorageService.Instance; + var data = await storageService.LoadWorkflowDataAsync(workflowId); + + if (data == null) + { + Log.Warning("[{Location}] Workflow data not found for ID: {WorkflowId}", location, workflowId); + return false; + } + + if (!data.UseBlockMode || string.IsNullOrWhiteSpace(data.BlockScriptSource)) + { + Log.Warning("[{Location}] Workflow '{Name}' has no BlockScript source to compile", + location, data.Name); + return false; + } + + // Parse the BlockScript + var parseResult = _blockScriptService.ParseBlockScript(data.BlockScriptSource); + if (!parseResult.IsSuccess || parseResult.Script == null) + { + Log.Warning("[{Location}] Failed to parse BlockScript for workflow '{Name}': {Error}", + location, data.Name, parseResult.ErrorMessage); + return false; + } + + // Attach helper functions + if (data.HelperFunctions != null) + parseResult.Script.HelperFunctions = data.HelperFunctions; + + // Use BlockScriptService for compilation (it owns BlockScriptExecutor) + var compiled = await _blockScriptService.CompileAndPersistAsync(parseResult.Script, workflowId); + + if (compiled) + { + Log.Information("[{Location}] Compiled and persisted workflow '{Name}' (ID: {WorkflowId})", + location, data.Name, workflowId); + } + else + { + Log.Warning("[{Location}] Compilation failed for workflow '{Name}' (ID: {WorkflowId})", + location, data.Name, workflowId); + } + + return compiled; + } + catch (Exception ex) + { + Log.Error(ex, "[{Location}] Error compiling workflow {WorkflowId}", location, workflowId); + return false; + } + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowPluginService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowPluginService.cs new file mode 100644 index 00000000..ff8acc60 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowPluginService.cs @@ -0,0 +1,178 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using KitX.Core.Contract.Workflow; +using KitX.Core.Device; +using KitX.Shared.CSharp.Plugin; +using Microsoft.CodeAnalysis.CSharp; +using Serilog; + +namespace KitX.Core.Workflow; + +/// +/// Plugin coordination service for workflow script processing. +/// Implements IWorkflowPluginService. +/// +internal class WorkflowPluginService : IWorkflowPluginService +{ + private readonly WorkflowRuntimeState _state; + + /// + /// Initializes a new instance of WorkflowPluginService. + /// + /// Shared runtime state. + internal WorkflowPluginService(WorkflowRuntimeState state) + { + _state = state; + } + + /// + public void InitializePluginManager() + { + if (_state.IsParserInitialized) return; + + try + { + var pluginsServer = PluginsServer.Instance; + var realPluginManager = new RealPluginManager(pluginsServer); + + Kscript.CSharp.Parser.Parser.SetPluginManager(realPluginManager); + + _state.IsParserInitialized = true; + Log.Information("[WorkflowPluginService] Real plugin manager initialized"); + } + catch (Exception ex) + { + Log.Error($"[WorkflowPluginService] Failed to initialize real plugin manager: {ex.Message}, falling back to mock"); + Kscript.CSharp.Parser.Parser.SetPluginManager(new Kscript.CSharp.Parser.Core.MockPluginManager()); + _state.IsParserInitialized = true; + } + } + + /// + public void UpdateAvailablePlugins(List plugins) + { + _state.AvailablePlugins = plugins ?? new List(); + Log.Information($"[WorkflowPluginService] Updated available plugins: {_state.AvailablePlugins.Count} plugins"); + } + + /// + public List ParseConstantsFromCode(string code) + { + var result = new List(); + + if (string.IsNullOrWhiteSpace(code)) + return result; + + var lines = code.Split('\n'); + var newConstants = new Dictionary(); + + foreach (var line in lines) + { + var trimmed = line.Trim(); + if (trimmed.StartsWith("const ")) + { + var parts = trimmed.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length >= 4 && parts[0] == "const") + { + var type = parts[1]; + var name = parts[2]; + var valueStr = string.Join(" ", parts.Skip(3)).TrimStart('=').Trim().TrimEnd(';'); + + if (!newConstants.ContainsKey(name)) + { + var defaultValue = ParseValue(valueStr, type); + + newConstants[name] = new VariableConstant + { + Name = name, + DefaultValue = defaultValue, + UserValue = defaultValue, + Type = type + }; + } + } + } + } + + return newConstants.Values.ToList(); + } + + /// + /// Parses a constant value string into the appropriate typed value. + /// + private static object? ParseValue(string valueStr, string type) + { + if (string.IsNullOrEmpty(valueStr)) return null; + + try + { + return type switch + { + "int" => int.TryParse(valueStr, out var i) ? i : 0, + "double" => double.TryParse(valueStr, out var d) ? d : 0.0, + "float" => float.TryParse(valueStr, out var f) ? f : 0.0f, + "bool" => bool.TryParse(valueStr, out var b) && b, + "string" => valueStr.Trim('"').Trim('\''), + _ => valueStr + }; + } + catch + { + return valueStr; + } + } + + /// + public string ApplyConstantsToCode(string code, List constants) + { + if (string.IsNullOrWhiteSpace(code) || constants == null || !constants.Any()) + return code; + + var constantValues = new Dictionary(); + foreach (var constant in constants) + { + var typedValue = constant.UserValue; + if (typedValue != null && constant.Type != null) + { + typedValue = ConvertToTypedValue(typedValue, constant.Type); + } + constantValues[constant.Name] = typedValue; + } + + var tree = CSharpSyntaxTree.ParseText(code); + var root = tree.GetRoot(); + + var rewriter = new ConstantValueRewriter(constantValues); + var newRoot = rewriter.Visit(root); + + return newRoot.ToFullString(); + } + + /// + /// Converts a value to the specified type. + /// + private static object? ConvertToTypedValue(object? value, string type) + { + if (value == null) return null; + + return type.ToLowerInvariant() switch + { + "int" => Convert.ToInt32(value), + "long" => Convert.ToInt64(value), + "double" => Convert.ToDouble(value), + "float" => Convert.ToSingle(value), + "decimal" => Convert.ToDecimal(value), + "bool" or "boolean" => Convert.ToBoolean(value), + "string" => value.ToString(), + "char" => Convert.ToChar(value), + _ => value + }; + } + + /// + public string MergeHelperFunctions(string mainCode, List helperFunctions) + { + return BlockScripting.HelperFunctionCodeGenerator.MergeWithMainProgram(mainCode, helperFunctions); + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowRuntimeState.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowRuntimeState.cs new file mode 100644 index 00000000..32628db5 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowRuntimeState.cs @@ -0,0 +1,45 @@ +using System.Collections.Generic; +using Csharpell.Core; +using KitX.Core.Contract.Workflow; +using KitX.Core.Workflow.BlockScripting; +using KitX.Shared.CSharp.Plugin; + +namespace KitX.Core.Workflow; + +/// +/// Shared state container for workflow runtime services. +/// Holds all mutable state used across IWorkflowManagementService, +/// IScriptExecutionService, IWorkflowPluginService, and IBlockScriptService. +/// +internal class WorkflowRuntimeState +{ + /// + /// In-memory workflow registry. + /// + internal readonly List Workflows = new(); + + /// + /// CSharpScript engine instance for script execution. + /// + internal CSharpScriptEngine? Engine; + + /// + /// Whether the plugin manager has been initialized. + /// + internal bool IsParserInitialized; + + /// + /// Available plugins for workflow execution. + /// + internal List AvailablePlugins { get; set; } = new(); + + /// + /// Block script parser instance. + /// + internal BlockScriptParser? BlockScriptParser; + + /// + /// Block script executor instance. + /// + internal BlockScriptExecutor? BlockScriptExecutor; +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs index 74bfcafd..8e1096bd 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs @@ -1,1113 +1,197 @@ using System; using System.Collections.Generic; -using System.Diagnostics; -using System.IO; -using System.Linq; -using System.Reflection; -using System.Text; using System.Threading; using System.Threading.Tasks; -using Csharpell.Core; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; -using Microsoft.CodeAnalysis.CSharp.Scripting; -using Kscript.CSharp.Parser; using KitX.Core.Contract.Workflow; using KitX.Core.Device; -using KitX.Core.Workflow.BlockScripting; using KitX.Shared.CSharp.Plugin; using Serilog; -using CTask = System.Threading.Tasks.Task; namespace KitX.Core.Workflow; /// -/// Workflow script service for executing workflows +/// Facade service that delegates to specialized workflow services. +/// Exposes all four workflow interfaces for backward compatibility. /// -public class WorkflowScriptService : IWorkflowService +public class WorkflowScriptService : IWorkflowManagementService, IScriptExecutionService, + IWorkflowPluginService, IBlockScriptService { private static WorkflowScriptService? _instance; /// - /// Gets the singleton instance + /// Gets the singleton instance. /// internal static WorkflowScriptService Instance => _instance ??= new(); - private readonly List _workflows = new(); - - /// - /// CSharpScriptEngine instance for script execution - /// - private CSharpScriptEngine? _engine; - - /// - /// Whether the plugin manager is initialized - /// - private bool _isParserInitialized = false; - - /// - /// Stores available plugins for workflow execution - /// - private List _availablePlugins { get; set; } = new(); - - /// - /// Private constructor - initializes RealPluginManager immediately - /// - private WorkflowScriptService() - { - // Pre-initialize RealPluginManager to ensure it subscribes to plugin events - // This must be done at startup, not when first script is executed - try - { - var pluginsServer = PluginsServer.Instance; - var realPluginManager = new RealPluginManager(pluginsServer); - Parser.SetPluginManager(realPluginManager); - _isParserInitialized = true; - Log.Information("[WorkflowScriptService] Real plugin manager pre-initialized at startup"); - } - catch (Exception ex) - { - Log.Warning(ex, "[WorkflowScriptService] Failed to pre-initialize RealPluginManager, will retry on first script execution"); - } - } - - /// - /// Gets the script engine, creating it if necessary - /// - private CSharpScriptEngine Engine => _engine ??= new CSharpScriptEngine(); - - /// - /// Gets the workflow list - /// - /// List of workflow cases - public IReadOnlyList GetWorkflows() - { - return _workflows.ToList(); - } - - /// - /// Runs a workflow by loading its data from storage and executing the BlockScript source. - /// - /// The workflow ID - /// True if run was successful - public async Task RunWorkflowAsync(string workflowId) - { - const string location = $"{nameof(WorkflowScriptService)}.{nameof(RunWorkflowAsync)}"; - - try - { - // Load workflow data from storage (not from the stale in-memory list) - var storageService = WorkflowStorageService.Instance; - var data = await storageService.LoadWorkflowDataAsync(workflowId); - - if (data == null) - { - Log.Warning("[{Location}] Workflow data not found in storage for ID: {WorkflowId}. " + - "Expected file path: {Path}", location, workflowId, - storageService.GetWorkflowFilePath(workflowId)); - return false; - } - - Log.Information("[{Location}] Loaded workflow '{Name}' (ID: {Id}), " + - "UseBlockMode: {UseBlockMode}, BlockScriptSource length: {BsLen}, " + - "MainProgram length: {MpLen}, Helpers: {HelperCount}", - location, data.Name, workflowId, data.UseBlockMode, - data.BlockScriptSource?.Length ?? 0, - data.MainProgram?.Length ?? 0, - data.HelperFunctions?.Count ?? 0); - - // Determine the source code to execute - string sourceCode; - List? helpers = data.HelperFunctions; - - if (data.UseBlockMode && !string.IsNullOrWhiteSpace(data.BlockScriptSource)) - { - sourceCode = data.BlockScriptSource; - } - else if (!string.IsNullOrWhiteSpace(data.MainProgram)) - { - sourceCode = data.MainProgram; - } - else - { - Log.Warning("[{Location}] Workflow '{Name}' (ID: {Id}) has no executable source code", - location, data.Name, workflowId); - return false; - } - - // Execute the BlockScript - Log.Information("[{Location}] Executing workflow '{Name}' ({SourceLength} chars)...", - location, data.Name, sourceCode.Length); - - // Set workflow ID for disk persistence of compiled assemblies - BlockScriptExecutor.SetWorkflowId(workflowId); - - var result = await ExecuteBlockScriptAsync(sourceCode, helpers ?? [], CancellationToken.None); - - if (result.IsSuccess) - { - var output = result.Output != null && result.Output.Count > 0 - ? string.Join("\n", result.Output) - : "(no output)"; - Log.Information("[{Location}] Workflow '{Name}' executed successfully. " + - "Blocks: {Blocks}, Time: {Time}ms\nOutput:\n{Output}", - location, data.Name, result.ExecutedBlockCount, result.ExecutionTimeMs, output); - } - else - { - Log.Error("[{Location}] Workflow '{Name}' execution failed: {Error}", - location, data.Name, result.ErrorMessage); - } - - return result.IsSuccess; - } - catch (Exception ex) - { - Log.Error(ex, "[{Location}] Error running workflow {WorkflowId}: {Message}", - location, workflowId, ex.Message); - return false; - } - } - - /// - /// Stops a running workflow by cancelling its execution. - /// - /// The workflow ID - /// True if stop was successful - public async Task StopWorkflowAsync(string workflowId) - { - const string location = $"{nameof(WorkflowScriptService)}.{nameof(StopWorkflowAsync)}"; - - try - { - var storageService = WorkflowStorageService.Instance; - var data = await storageService.LoadWorkflowDataAsync(workflowId); - - if (data == null) - { - Log.Warning("[{Location}] Workflow data not found in storage for ID: {WorkflowId}", - location, workflowId); - return false; - } - - // TODO: Implement actual cancellation via CancellationTokenSource tracking - Log.Information("[{Location}] Stop requested for workflow '{Name}' (ID: {WorkflowId}) — " + - "cancellation not yet implemented, workflow will complete current execution", - location, data.Name, workflowId); - - return true; - } - catch (Exception ex) - { - Log.Error(ex, "[{Location}] Error stopping workflow {WorkflowId}: {Message}", - location, workflowId, ex.Message); - return false; - } - } - - /// - /// Executes a workflow script - /// - /// The script content - /// Optional parameters - /// The execution result - public async Task ExecuteScriptAsync(string script, Dictionary? parameters = null) - { - const string location = $"{nameof(WorkflowScriptService)}.{nameof(ExecuteScriptAsync)}"; - - try - { - if (string.IsNullOrWhiteSpace(script)) - { - Log.Warning("Script is empty"); - return await System.Threading.Tasks.Task.FromResult(null); - } - - // Execute using the full ExecuteCodesAsync method - var result = await ExecuteCodesAsync(script, null, true, CancellationToken.None); - - return await System.Threading.Tasks.Task.FromResult(result); - } - catch (Exception ex) - { - Log.Error(ex, $"In {location}: Error executing script: {ex.Message}"); - return await System.Threading.Tasks.Task.FromResult(null); - } - } - - /// - /// Adds a workflow - /// - /// The workflow to add - public void AddWorkflow(IWorkflowCase workflow) - { - if (workflow != null && !_workflows.Any(w => w.Id == workflow.Id)) - { - _workflows.Add(workflow); - } - } - - /// - /// Removes a workflow - /// - /// The workflow ID - public void RemoveWorkflow(string workflowId) - { - var workflow = _workflows.FirstOrDefault(w => w.Id == workflowId); - if (workflow != null) - { - _workflows.Remove(workflow); - } - } - /// - /// Executes workflow script codes with plugin dependencies + /// Shared runtime state across all workflow services. /// - public async Task ExecuteCodesAsync( - string code, - List? requiredPlugins = null, - bool includeTimestamp = true, - CancellationToken cancellationToken = default) - { - const string location = $"{nameof(WorkflowScriptService)}.{nameof(ExecuteCodesAsync)}"; - - var sw = new Stopwatch(); - var begin = DateTime.Now; - sw.Start(); - - try - { - // If plugins are required, ensure they are loaded - if (requiredPlugins != null && requiredPlugins.Any()) - { - // Ensure plugin manager is initialized - if (!_isParserInitialized) - { - InitializePluginManager(); - } - - // Ensure plugins are ready - var pluginsReady = await EnsurePluginsReadyAsync(requiredPlugins, cancellationToken); - - if (!pluginsReady) - { - Log.Warning("[WorkflowScriptService] Some plugins failed to start, but continuing script execution"); - } - - // Generate plugin API assembly - Assembly? pluginApiAssembly = null; - try - { - // Disable cache during development to ensure RealPluginManager is used - pluginApiAssembly = Parser.Generate(requiredPlugins, "KitXWorkflowPlugins", useCache: false); - Log.Information($"[WorkflowScriptService] Successfully generated plugin API with {requiredPlugins.Count} plugins"); - } - catch (Exception ex) - { - var error = $"Failed to generate plugin API: {ex.Message}"; - Log.Error(ex, error); - } - - // Execute script with plugins - var result = await ExecuteScriptWithPluginsAsync( - code, - pluginApiAssembly, - includeTimestamp, - cancellationToken - ); - - sw.Stop(); - - return FormatExecutionResult(result, begin, sw.ElapsedMilliseconds, includeTimestamp); - } - else - { - // No plugin dependencies, execute directly - return await ExecuteCodesWithoutPluginsAsync(code, includeTimestamp, cancellationToken); - } - } - catch (Exception ex) - { - sw.Stop(); - - Log.Error(ex, $"In {location}: Error executing code: {ex.Message}"); - - return FormatExecutionError(ex, begin, sw.ElapsedMilliseconds, includeTimestamp); - } - } - - /// - /// Executes script with plugin dependencies - /// - private async Task ExecuteScriptWithPluginsAsync( - string code, - Assembly? pluginApiAssembly, - bool includeTimestamp, - CancellationToken cancellationToken) - { - try - { - // Clear previous output before execution - WorkflowOutput.GetAndClear(); - - var result = await Engine.ExecuteAsync( - code, - options => - { - options = options - .WithReferences(Assembly.GetExecutingAssembly()) - .WithImports( - "KitX", - "KitX.Core", - "KitX.Core.Workflow", - "KitX.Shared.CSharp.Plugin", - "System", - "System.Collections.Generic", - "System.Threading.Tasks" - ) - .WithLanguageVersion(LanguageVersion.Preview); - - // Add plugin API assembly if available - if (pluginApiAssembly != null) - { - options = options.WithReferences(pluginApiAssembly); - } - - return options; - }, - addDefaultImports: true, - runInReplMode: false, - cancellationToken: cancellationToken - ); - - // Get output from WorkflowOutput and combine with return value - var scriptOutput = WorkflowOutput.GetAndClear(); - var returnValue = result?.ToString(); - - return CombineOutput(scriptOutput, returnValue); - } - catch (Exception ex) - { - Log.Error(ex, "[WorkflowScriptService] Error executing script with plugins"); - return $"Script execution error: {ex.Message}"; - } - } + private static readonly WorkflowRuntimeState SharedState = new(); /// - /// Ensures required plugins are loaded and running + /// Service graph — built once at singleton instantiation. /// - private async Task EnsurePluginsReadyAsync( - List requiredPlugins, - CancellationToken cancellationToken = default) - { - // Note: This requires IPluginService to check if plugins are running - // For now, we log the required plugins - Log.Information($"[WorkflowScriptService] Checking {requiredPlugins.Count} required plugins"); - - foreach (var plugin in requiredPlugins) - { - Log.Information($"[WorkflowScriptService] Required plugin: {plugin.Name}"); - } - - // Simplified: assume all plugins are ready - // Full implementation would use IPluginService to check status - return await System.Threading.Tasks.Task.FromResult(true); - } + private static readonly IBlockScriptService BlockScriptService; + private static readonly IWorkflowPluginService PluginService; + private static readonly IScriptExecutionService ScriptExecutionService; + private static readonly IWorkflowManagementService ManagementService; /// - /// Executes code without plugin dependencies + /// Static constructor initializes the service graph in dependency order. /// - private async Task ExecuteCodesWithoutPluginsAsync( - string code, - bool includeTimestamp, - CancellationToken cancellationToken) + static WorkflowScriptService() { - var sw = new Stopwatch(); - var begin = DateTime.Now; - sw.Start(); - - try - { - // Clear previous output before execution - WorkflowOutput.GetAndClear(); - - var result = await Engine.ExecuteAsync( - code, - options => options - .WithReferences(Assembly.GetExecutingAssembly()) - .WithImports( - "System", - "System.Collections.Generic", - "System.Threading.Tasks", - "KitX.Core.Workflow" - ) - .WithLanguageVersion(LanguageVersion.Preview), - addDefaultImports: true, - runInReplMode: false, - cancellationToken: cancellationToken - ); + // 1. BlockScriptService (no cross-service dependencies) + BlockScriptService = new BlockScriptServiceImpl(SharedState); - sw.Stop(); + // 2. PluginService (depends only on SharedState) + PluginService = new WorkflowPluginService(SharedState); - // Get output from WorkflowOutput and combine with return value - var scriptOutput = WorkflowOutput.GetAndClear(); - var returnValue = result?.ToString(); + // 3. ScriptExecutionService (depends on PluginService) + ScriptExecutionService = new ScriptExecutionService(SharedState, PluginService); - var combinedOutput = CombineOutput(scriptOutput, returnValue); - - return FormatExecutionResult(combinedOutput, begin, sw.ElapsedMilliseconds, includeTimestamp); - } - catch (Exception ex) - { - sw.Stop(); - return FormatExecutionError(ex, begin, sw.ElapsedMilliseconds, includeTimestamp); - } - } - - /// - /// Initializes the plugin manager - /// - public void InitializePluginManager() - { - if (_isParserInitialized) return; + // 4. ManagementService (depends on BlockScriptService) + ManagementService = new WorkflowManagementService(SharedState, BlockScriptService); + // Pre-initialize RealPluginManager at startup try { - // Use the real plugin manager that communicates via WebSocket var pluginsServer = PluginsServer.Instance; var realPluginManager = new RealPluginManager(pluginsServer); - - Parser.SetPluginManager(realPluginManager); - - _isParserInitialized = true; - Log.Information("[WorkflowScriptService] Real plugin manager initialized"); + Kscript.CSharp.Parser.Parser.SetPluginManager(realPluginManager); + SharedState.IsParserInitialized = true; + Log.Information("[WorkflowScriptService] Real plugin manager pre-initialized at startup"); } catch (Exception ex) { - Log.Error($"[WorkflowScriptService] Failed to initialize real plugin manager: {ex.Message}, falling back to mock"); - // Use mock manager as fallback - Parser.SetPluginManager(new Kscript.CSharp.Parser.Core.MockPluginManager()); - _isParserInitialized = true; + Log.Warning(ex, "[WorkflowScriptService] Failed to pre-initialize RealPluginManager, will retry on first script execution"); } } /// - /// Updates the available plugins list + /// Exposes the shared runtime state for DI registration. /// - public void UpdateAvailablePlugins(List plugins) - { - _availablePlugins = plugins ?? new List(); - Log.Information($"[WorkflowScriptService] Updated available plugins: {_availablePlugins.Count} plugins"); - } - - #region Execution Result Formatting Helpers + internal static WorkflowRuntimeState RuntimeState => SharedState; /// - /// Formats successful execution output with optional timestamp header + /// Exposes IBlockScriptService for DI registration. /// - private static string? FormatExecutionResult(string? output, DateTime begin, long elapsedMs, bool includeTimestamp) - { - if (!includeTimestamp) - return output; - - return new StringBuilder() - .AppendLine($"[{begin:yyyy-MM-dd HH:mm:ss}] [I] Workflow script posted.") - .AppendLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [I] Script ended, took {elapsedMs} ms.") - .AppendLine(output) - .ToString(); - } + internal static IBlockScriptService BlockScriptServiceInstance => BlockScriptService; /// - /// Formats execution error with optional timestamp header + /// Exposes IWorkflowPluginService for DI registration. /// - private static string? FormatExecutionError(Exception ex, DateTime begin, long elapsedMs, bool includeTimestamp) - { - if (!includeTimestamp) - return ex.StackTrace; - - return new StringBuilder() - .AppendLine($"[{begin:yyyy-MM-dd HH:mm:ss}] [I] Workflow script posted.") - .AppendLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [E] Exception caught after {elapsedMs} ms, Message: {ex.Message}") - .AppendLine(ex.StackTrace) - .ToString(); - } + internal static IWorkflowPluginService PluginServiceInstance => PluginService; /// - /// Combines WorkflowOutput and script return value into a single output string + /// Exposes IScriptExecutionService for DI registration. /// - private static string? CombineOutput(string? scriptOutput, string? returnValue) - { - return !string.IsNullOrEmpty(scriptOutput) - ? (string.IsNullOrEmpty(returnValue) - ? scriptOutput - : $"{scriptOutput}\n{returnValue}") - : returnValue; - } - - #endregion - - #region KCS Script Processing Methods + internal static IScriptExecutionService ScriptExecutionServiceInstance => ScriptExecutionService; /// - /// 从代码中解析常量 + /// Exposes IWorkflowManagementService for DI registration. /// - public List ParseConstantsFromCode(string code) - { - var result = new List(); - - if (string.IsNullOrWhiteSpace(code)) - return result; - - // 简单的const解析 - 匹配 "const 类型 变量名 = 值;" 模式 - var lines = code.Split('\n'); - var newConstants = new Dictionary(); - - foreach (var line in lines) - { - var trimmed = line.Trim(); - if (trimmed.StartsWith("const ")) - { - // 解析 const 类型 名称 = 值; - var parts = trimmed.Split(' ', StringSplitOptions.RemoveEmptyEntries); - if (parts.Length >= 4 && parts[0] == "const") - { - var type = parts[1]; - var name = parts[2]; - var valueStr = string.Join(" ", parts.Skip(3)).TrimStart('=').Trim().TrimEnd(';'); - - if (!newConstants.ContainsKey(name)) - { - var defaultValue = ParseValue(valueStr, type); + internal static IWorkflowManagementService ManagementServiceInstance => ManagementService; - newConstants[name] = new VariableConstant - { - Name = name, - DefaultValue = defaultValue, - UserValue = defaultValue, - Type = type - }; - } - } - } - } - - return newConstants.Values.ToList(); + private WorkflowScriptService() + { + // Private constructor to enforce singleton usage. } - /// - /// 解析常量值 - /// - private object? ParseValue(string valueStr, string type) - { - if (string.IsNullOrEmpty(valueStr)) return null; + // --- IWorkflowManagementService --- - try - { - return type switch - { - "int" => int.TryParse(valueStr, out var i) ? i : 0, - "double" => double.TryParse(valueStr, out var d) ? d : 0.0, - "float" => float.TryParse(valueStr, out var f) ? f : 0.0f, - "bool" => bool.TryParse(valueStr, out var b) && b, - "string" => valueStr.Trim('"').Trim('\''), - _ => valueStr - }; - } - catch - { - return valueStr; - } - } + public IReadOnlyList GetWorkflows() => ManagementService.GetWorkflows(); - /// - /// 应用常量到代码 - /// - /// - /// 使用 CSharpSyntaxRewriter 进行语法树级别的值注入,比正则表达式更准确可靠, - /// 不会误匹配注释或字符串中的内容。 - /// - public string ApplyConstantsToCode(string code, List constants) - { - if (string.IsNullOrWhiteSpace(code) || constants == null || !constants.Any()) - return code; + public void AddWorkflow(IWorkflowCase workflow) => ManagementService.AddWorkflow(workflow); - // 构建常量名称到值的字典 - var constantValues = new Dictionary(); - foreach (var constant in constants) - { - // 根据类型转换 UserValue - var typedValue = constant.UserValue; - if (typedValue != null && constant.Type != null) - { - typedValue = ConvertToTypedValue(typedValue, constant.Type); - } - constantValues[constant.Name] = typedValue; - } + public void RemoveWorkflow(string workflowId) => ManagementService.RemoveWorkflow(workflowId); - // 解析代码为语法树 - var tree = CSharpSyntaxTree.ParseText(code); - var root = tree.GetRoot(); + public Task RunWorkflowAsync(string workflowId) => ManagementService.RunWorkflowAsync(workflowId); - // 使用语法重写器注入常量值 - var rewriter = new ConstantValueRewriter(constantValues); - var newRoot = rewriter.Visit(root); + public Task StopWorkflowAsync(string workflowId) => ManagementService.StopWorkflowAsync(workflowId); - return newRoot.ToFullString(); - } + public Task CompileAndPersistWorkflowAsync(string workflowId) => + ManagementService.CompileAndPersistWorkflowAsync(workflowId); - /// - /// 将值转换为指定类型 - /// - private object? ConvertToTypedValue(object? value, string type) - { - if (value == null) return null; + // --- IScriptExecutionService --- - return type.ToLowerInvariant() switch - { - "int" => Convert.ToInt32(value), - "long" => Convert.ToInt64(value), - "double" => Convert.ToDouble(value), - "float" => Convert.ToSingle(value), - "decimal" => Convert.ToDecimal(value), - "bool" or "boolean" => Convert.ToBoolean(value), - "string" => value.ToString(), - "char" => Convert.ToChar(value), - _ => value - }; - } + public Task ExecuteScriptAsync(string script, Dictionary? parameters = null) => + ScriptExecutionService.ExecuteScriptAsync(script, parameters); - /// - /// 合并辅助函数到代码 - /// - public string MergeHelperFunctions(string mainCode, List helperFunctions) - { - return BlockScripting.HelperFunctionCodeGenerator.MergeWithMainProgram(mainCode, helperFunctions); - } + public Task ExecuteCodesAsync( + string code, + List? requiredPlugins = null, + bool includeTimestamp = true, + CancellationToken cancellationToken = default) => + ScriptExecutionService.ExecuteCodesAsync(code, requiredPlugins, includeTimestamp, cancellationToken); - /// - /// 执行KCS代码 - 包含代码分析、常量应用、辅助函数合并 - /// - public async Task ExecuteKcsCodesAsync( + public Task ExecuteKcsCodesAsync( string mainCode, List helperFunctions, List constants, List? requiredPlugins = null, bool includeTimestamp = true, - CancellationToken cancellationToken = default) - { - // 1. 分析主程序代码 - var analyzer = new MainProgramAnalyzer(); - var analysisResult = analyzer.Analyze(mainCode); + CancellationToken cancellationToken = default) => + ScriptExecutionService.ExecuteKcsCodesAsync(mainCode, helperFunctions, constants, requiredPlugins, includeTimestamp, cancellationToken); - if (!analysisResult.IsValid) - { - return includeTimestamp - ? $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [E] Code analysis failed: {analysisResult.ForbiddenReason}" - : $"Code analysis failed: {analysisResult.ForbiddenReason}"; - } - - // 2. 应用常量 - var codeWithConstants = ApplyConstantsToCode(mainCode, constants); - - // 3. 合并辅助函数 - var fullCode = MergeHelperFunctions(codeWithConstants, helperFunctions); - - // 4. 执行代码 - return await ExecuteCodesAsync(fullCode, requiredPlugins, includeTimestamp, cancellationToken); - } - - #region Block Script Methods - - // Block script parser instance - private BlockScripting.BlockScriptParser? _blockScriptParser; - - // Block script executor instance - private BlockScripting.BlockScriptExecutor? _blockScriptExecutor; - - /// - /// Gets the block script parser - /// - private BlockScripting.BlockScriptParser BlockScriptParser => - _blockScriptParser ??= new BlockScripting.BlockScriptParser( - BuiltinFunctionRegistry.Discover(typeof(BuiltinFunctionRegistry).Assembly)); + // --- IWorkflowPluginService --- - /// - /// Gets the block script executor (initialized with plugin manager) - /// - private BlockScripting.BlockScriptExecutor BlockScriptExecutor - { - get - { - if (_blockScriptExecutor == null) - { - _blockScriptExecutor = new BlockScripting.BlockScriptExecutor(); - if (_isParserInitialized) - { - var realPluginManager = new RealPluginManager(PluginsServer.Instance); - _blockScriptExecutor.SetPluginManager(realPluginManager); - } - } - return _blockScriptExecutor; - } - } + public void InitializePluginManager() => PluginService.InitializePluginManager(); - /// - /// 解析块脚本 - /// - public BlockScriptParseResult ParseBlockScript(string sourceCode) - { - return BlockScriptParser.Parse(sourceCode); - } + public void UpdateAvailablePlugins(List plugins) => + PluginService.UpdateAvailablePlugins(plugins); - /// - /// 异步解析块脚本 - /// - public Task ParseBlockScriptAsync(string sourceCode) - { - return BlockScriptParser.ParseAsync(sourceCode); - } + public List ParseConstantsFromCode(string code) => + PluginService.ParseConstantsFromCode(code); - /// - /// 验证块脚本 - /// - public BlockScriptValidationResult ValidateBlockScript(string sourceCode) - { - return BlockScriptParser.Validate(sourceCode); - } + public string ApplyConstantsToCode(string code, List constants) => + PluginService.ApplyConstantsToCode(code, constants); - /// - /// 从 BlockScript 源码的 #ConstBlock 中解析有初始值的常量 - /// - public List ParseConstantsFromBlockScript(string sourceCode) - { - var result = new List(); + public string MergeHelperFunctions(string mainCode, List helperFunctions) => + PluginService.MergeHelperFunctions(mainCode, helperFunctions); - if (string.IsNullOrWhiteSpace(sourceCode)) - return result; + // --- IBlockScriptService --- - try - { - var parseResult = BlockScriptParser.Parse(sourceCode); + public BlockScriptParseResult ParseBlockScript(string sourceCode) => + BlockScriptService.ParseBlockScript(sourceCode); - if (!parseResult.IsSuccess || parseResult.Script?.ConstBlock == null) - return result; + public Task ParseBlockScriptAsync(string sourceCode) => + BlockScriptService.ParseBlockScriptAsync(sourceCode); - foreach (var variable in parseResult.Script.ConstBlock.Variables) - { - if (variable.DefaultValue != null) - { - result.Add(new VariableConstant - { - Name = variable.Name, - DefaultValue = variable.DefaultValue, - UserValue = variable.DefaultValue, - Type = variable.Type - }); - } - } - } - catch (Exception ex) - { - Log.Warning(ex, "[WorkflowScriptService] Error parsing constants from BlockScript"); - } + public BlockScriptValidationResult ValidateBlockScript(string sourceCode) => + BlockScriptService.ValidateBlockScript(sourceCode); - return result; - } + public List ParseConstantsFromBlockScript(string sourceCode) => + BlockScriptService.ParseConstantsFromBlockScript(sourceCode); - /// - /// 执行块脚本(从已解析的 BlockScript 对象) - /// public Task ExecuteBlockScriptAsync( BlockScript script, Dictionary? parameters = null, - CancellationToken cancellationToken = default) - { - return BlockScriptExecutor.ExecuteAsync(script, parameters, cancellationToken); - } + CancellationToken cancellationToken = default) => + BlockScriptService.ExecuteBlockScriptAsync(script, parameters, cancellationToken); - /// - /// 从块脚本源代码执行 - /// public Task ExecuteBlockScriptAsync( string sourceCode, Dictionary? parameters = null, - CancellationToken cancellationToken = default) - { - return ExecuteBlockScriptCoreAsync(sourceCode, null, parameters, cancellationToken); - } + CancellationToken cancellationToken = default) => + BlockScriptService.ExecuteBlockScriptAsync(sourceCode, parameters, cancellationToken); - /// - /// 从块脚本源代码执行(带辅助函数) - /// public Task ExecuteBlockScriptAsync( string sourceCode, List helperFunctions, - CancellationToken cancellationToken = default) - { - return ExecuteBlockScriptCoreAsync(sourceCode, helperFunctions, null, cancellationToken); - } + CancellationToken cancellationToken = default) => + BlockScriptService.ExecuteBlockScriptAsync(sourceCode, helperFunctions, cancellationToken); - /// - /// 从块脚本源代码执行(带辅助函数和常量覆盖) - /// public Task ExecuteBlockScriptAsync( string sourceCode, List helperFunctions, Dictionary? constantOverrides, - CancellationToken cancellationToken = default) - { - return ExecuteBlockScriptCoreAsync(sourceCode, helperFunctions, constantOverrides, cancellationToken); - } - - /// - /// 核心块脚本执行逻辑:解析 → 验证 → 执行 - /// - private async Task ExecuteBlockScriptCoreAsync( - string sourceCode, - List? helperFunctions, - Dictionary? constantOverrides, - CancellationToken cancellationToken) - { - // 1. Parse - var parseResult = BlockScriptParser.Parse(sourceCode); - - if (!parseResult.IsSuccess || parseResult.Script == null) - { - return new BlockScriptExecutionResult - { - IsSuccess = false, - ErrorMessage = parseResult.ErrorMessage ?? "Failed to parse block script" - }; - } - - // 2. Validate - var validationResult = BlockScriptExecutor.Validate(parseResult.Script); - if (!validationResult.IsValid) - { - return new BlockScriptExecutionResult - { - IsSuccess = false, - ErrorMessage = string.Join("; ", validationResult.Errors) - }; - } - - // 3. Attach helper functions if provided - if (helperFunctions != null) - parseResult.Script.HelperFunctions = helperFunctions; - - // 3.5. Apply constant overrides from user edits (replaces DefaultValue before execution) - if (constantOverrides != null && parseResult.Script.ConstBlock != null) - { - foreach (var variable in parseResult.Script.ConstBlock.Variables) - { - if (constantOverrides.TryGetValue(variable.Name, out var userValue)) - { - variable.DefaultValue = userValue; - } - } - } - - // 4. Execute - return await BlockScriptExecutor.ExecuteAsync(parseResult.Script, constantOverrides, cancellationToken); - } - - /// - public async Task CompileAndPersistWorkflowAsync(string workflowId) - { - const string location = $"{nameof(WorkflowScriptService)}.{nameof(CompileAndPersistWorkflowAsync)}"; - - try - { - var storageService = WorkflowStorageService.Instance; - var data = await storageService.LoadWorkflowDataAsync(workflowId); - - if (data == null) - { - Log.Warning("[{Location}] Workflow data not found for ID: {WorkflowId}", location, workflowId); - return false; - } - - if (!data.UseBlockMode || string.IsNullOrWhiteSpace(data.BlockScriptSource)) - { - Log.Warning("[{Location}] Workflow '{Name}' has no BlockScript source to compile", - location, data.Name); - return false; - } - - // Parse the BlockScript - var parseResult = BlockScriptParser.Parse(data.BlockScriptSource); - if (!parseResult.IsSuccess || parseResult.Script == null) - { - Log.Warning("[{Location}] Failed to parse BlockScript for workflow '{Name}': {Error}", - location, data.Name, parseResult.ErrorMessage); - return false; - } - - // Attach helper functions - if (data.HelperFunctions != null) - parseResult.Script.HelperFunctions = data.HelperFunctions; - - // Compile with workflowId for disk persistence - BlockScriptExecutor.SetWorkflowId(workflowId); - var compiled = BlockScriptExecutor.CompileForPersistence(parseResult.Script, workflowId); - - if (compiled) - { - Log.Information("[{Location}] Compiled and persisted workflow '{Name}' (ID: {WorkflowId})", - location, data.Name, workflowId); - } - else - { - Log.Warning("[{Location}] Compilation failed for workflow '{Name}' (ID: {WorkflowId})", - location, data.Name, workflowId); - } - - return compiled; - } - catch (Exception ex) - { - Log.Error(ex, "[{Location}] Error compiling workflow {WorkflowId}", location, workflowId); - return false; - } - } - - /// - /// Preloads all persisted compiled scripts for a workflow from disk. - /// Called at startup or when a workflow is first accessed. - /// - /// Workflow ID to preload scripts for. - /// Number of scripts loaded from disk. - public int PreloadCompiledScripts(string workflowId) - { - return BlockScriptExecutor.PreloadFromDisk(workflowId); - } - - #endregion - - #endregion -} - -/// -/// Rewriter for injecting constant values into variable declarations using CSharpSyntaxRewriter -/// -/// -/// This approach is more reliable than regex-based matching as it understands the actual -/// syntax structure and won't accidentally match content in comments or strings. -/// -internal class ConstantValueRewriter : CSharpSyntaxRewriter -{ - private readonly Dictionary _constantValues; - - public ConstantValueRewriter(Dictionary constantValues) - { - _constantValues = constantValues; - } - - /// - /// Visits variable declarators to replace their initializer values - /// - public override SyntaxNode? VisitVariableDeclarator(VariableDeclaratorSyntax node) - { - // Check if this variable declarator has an initializer and matches a constant name - if (node.Initializer != null && _constantValues.TryGetValue(node.Identifier.Text, out var newValue)) - { - var newInitializer = CreateNewInitializer(node.Initializer, newValue); - if (newInitializer != null) - { - return node.WithInitializer(newInitializer); - } - } + CancellationToken cancellationToken = default) => + BlockScriptService.ExecuteBlockScriptAsync(sourceCode, helperFunctions, constantOverrides, cancellationToken); - return base.VisitVariableDeclarator(node); - } - - /// - /// Creates a new EqualsValueClauseSyntax with the specified value - /// - private EqualsValueClauseSyntax? CreateNewInitializer(EqualsValueClauseSyntax oldInitializer, object? value) - { - ExpressionSyntax? newExpression = null; - - if (value == null) - { - newExpression = SyntaxFactory.LiteralExpression(SyntaxKind.NullLiteralExpression); - } - else if (value is int intVal) - { - newExpression = SyntaxFactory.LiteralExpression( - SyntaxKind.NumericLiteralExpression, - SyntaxFactory.Literal(intVal)); - } - else if (value is long longVal) - { - newExpression = SyntaxFactory.LiteralExpression( - SyntaxKind.NumericLiteralExpression, - SyntaxFactory.Literal(longVal)); - } - else if (value is double doubleVal) - { - newExpression = SyntaxFactory.LiteralExpression( - SyntaxKind.NumericLiteralExpression, - SyntaxFactory.Literal(doubleVal)); - } - else if (value is float floatVal) - { - newExpression = SyntaxFactory.LiteralExpression( - SyntaxKind.NumericLiteralExpression, - SyntaxFactory.Literal(floatVal)); - } - else if (value is decimal decimalVal) - { - newExpression = SyntaxFactory.LiteralExpression( - SyntaxKind.NumericLiteralExpression, - SyntaxFactory.Literal(decimalVal)); - } - else if (value is bool boolVal) - { - newExpression = SyntaxFactory.LiteralExpression( - boolVal ? SyntaxKind.TrueLiteralExpression : SyntaxKind.FalseLiteralExpression); - } - else if (value is string stringVal) - { - newExpression = SyntaxFactory.LiteralExpression( - SyntaxKind.StringLiteralExpression, - SyntaxFactory.Literal(stringVal)); - } - else if (value is char charVal) - { - newExpression = SyntaxFactory.LiteralExpression( - SyntaxKind.CharacterLiteralExpression, - SyntaxFactory.Literal(charVal)); - } - - if (newExpression != null) - { - return SyntaxFactory.EqualsValueClause(newExpression) - .WithLeadingTrivia(oldInitializer.GetLeadingTrivia()) - .WithTrailingTrivia(oldInitializer.GetTrailingTrivia()); - } - - return null; - } -} - -/// -/// Workflow case implementation -/// -public class WorkflowCase : IWorkflowCase -{ - public string Id { get; set; } = Guid.NewGuid().ToString(); - public string Name { get; set; } = "Untitled Workflow"; - public string Description { get; set; } = string.Empty; - public string Author { get; set; } = string.Empty; - public bool IsRunning { get; set; } - public bool IsError { get; set; } - public string? ErrorMessage { get; set; } - public string? ScriptPath { get; set; } - public DateTime CreatedTime { get; set; } = DateTime.UtcNow; - public DateTime LastModifiedTime { get; set; } = DateTime.UtcNow; - public string TriggerType { get; set; } = "Manual"; + public Task CompileAndPersistAsync(BlockScript script, string workflowId) => + BlockScriptService.CompileAndPersistAsync(script, workflowId); - public TriggerConfig? TriggerConfig { get; set; } + public int PreloadCompiledScripts(string workflowId) => + BlockScriptService.PreloadCompiledScripts(workflowId); } diff --git a/KitX Clients/KitX Dashboard b/KitX Clients/KitX Dashboard index 6087f854..4b48a1be 160000 --- a/KitX Clients/KitX Dashboard +++ b/KitX Clients/KitX Dashboard @@ -1 +1 @@ -Subproject commit 6087f854d692c95aaba3fee56c7c757656d714e1 +Subproject commit 4b48a1bec0ce048f6d17a8b8da9ccbf6988074bc diff --git a/KitX Standard b/KitX Standard index 7d38df8e..7ff6a867 160000 --- a/KitX Standard +++ b/KitX Standard @@ -1 +1 @@ -Subproject commit 7d38df8ebe06054755fe678e4a5027520791abee +Subproject commit 7ff6a86725dbb808ed548dfc6306723d4881c487 From 3c604fc622bd4124c3191386a25423b5c037fd55 Mon Sep 17 00:00:00 2001 From: StarInk Date: Mon, 20 Apr 2026 22:40:03 +0200 Subject: [PATCH 059/110] =?UTF-8?q?=F0=9F=A7=A9=20Refactor(Core):=20CFG=20?= =?UTF-8?q?statement=20handling=20and=20introduce=20ServiceHost=20for=20DI?= =?UTF-8?q?=20management?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replaced references to FormattedStatementKind with CFGStatementKind across multiple files to unify statement handling in the control flow graph (CFG). - Enhanced ScriptCodeGenerator to include ConstBlock variables in public variable types. - Updated CFGBuilderFromBlueprint and CFGBuilderFromScript to directly use CFGStatementKind. - Removed the obsolete ConvertKind method and simplified statement kind handling. - Introduced ServiceHost class for centralized dependency injection management, ensuring singleton consistency across services. - Added ServerBase class to standardize server status management and lifecycle patterns for device servers. - Improved logging and error handling in RealPluginManager and TriggerManager. - Updated WorkflowStorageService to utilize ServiceHost for instance management. --- .../KitX.Core.BluePrint.Test/Program.cs | 214 +++++ .../KitX.Core/Activity/ActivityManager.cs | 30 +- .../Announcement/AnnouncementManager.cs | 128 +-- .../KitX.Core/Configuration/ConfigManager.cs | 41 +- .../DI/CoreServiceCollectionExtensions.cs | 146 ++- .../KitX Core/KitX.Core/DI/ServiceHost.cs | 87 ++ .../KitX Core/KitX.Core/Device/DeviceCase.cs | 1 + .../Device/DevicesDiscoveryServer.cs | 51 +- .../KitX.Core/Device/DevicesOrganizer.cs | 43 +- .../KitX.Core/Device/DevicesServer.cs | 60 +- .../KitX.Core/Device/PluginConnection.cs | 17 +- .../KitX.Core/Device/PluginsServer.cs | 167 ++-- .../KitX Core/KitX.Core/Device/ServerBase.cs | 126 +++ .../KitX Core/KitX.Core/Event/EventArgs.cs | 1 + .../KitX Core/KitX.Core/Event/EventNames.cs | 10 + .../KitX Core/KitX.Core/Event/EventService.cs | 26 +- .../FileWatcher/FileWatcherManager.cs | 27 +- .../KitX.Core/Hotkey/KeyHookManager.cs | 27 +- .../KitX.Core/Plugin/PluginsManager.cs | 67 +- .../KitX.Core/Security/SecurityManager.cs | 51 +- .../KitX.Core/Statistics/StatisticsManager.cs | 32 +- .../KitX Core/KitX.Core/Tasks/TasksManager.cs | 26 +- .../Workflow/BlockScriptServiceImpl.cs | 39 +- .../Workflow/BlockScripting/BlockCompiler.cs | 235 ----- .../BlockScripting/BlockScriptExecutor.cs | 895 +----------------- .../BuiltinFunctions/BranchFunction.cs | 3 +- .../BuiltinFunctions/BreakFunction.cs | 5 +- .../BuiltinFunctions/FlipFunction.cs | 5 +- .../BuiltinFunctions/GetFunction.cs | 5 +- .../BuiltinFunctions/LoopFunction.cs | 3 +- .../BuiltinFunctions/PauseFunction.cs | 5 +- .../BuiltinFunctions/PluginCallFunction.cs | 5 +- .../PluginCallWithTargetFunction.cs | 113 ++- .../BuiltinFunctions/PrintFunction.cs | 5 +- .../BuiltinFunctions/SetFunction.cs | 5 +- .../BuiltinFunctions/ToLoopCondFunction.cs | 3 +- .../BuiltinFunctions/TryGetDeviceFunction.cs | 6 +- .../IBuiltinFunctionDefinition.cs | 5 +- .../BlockScripting/ScriptCodeGenerator.cs | 32 +- .../Blueprint/CFG/CFGBuilderFromBlueprint.cs | 9 +- .../Blueprint/CFG/CFGBuilderFromScript.cs | 30 +- .../Workflow/Blueprint/CFG/CFGStatement.cs | 14 +- .../Blueprint/Pipeline/DataEdgeBuilder.cs | 15 +- .../Pipeline/FormattedBlockScript.cs | 54 +- .../Blueprint/Pipeline/NodeBuilder.cs | 29 +- .../Blueprint/Pipeline/ScriptFormatter.cs | 27 +- .../KitX.Core/Workflow/RealPluginManager.cs | 25 +- .../KitX.Core/Workflow/TriggerManager.cs | 68 +- .../Workflow/WorkflowRuntimeState.cs | 2 +- .../Workflow/WorkflowScriptService.cs | 82 +- .../Workflow/WorkflowStorageService.cs | 29 +- KitX Clients/KitX Dashboard | 2 +- KitX Standard | 2 +- 53 files changed, 1515 insertions(+), 1620 deletions(-) create mode 100644 KitX Clients/KitX Core/KitX.Core/DI/ServiceHost.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Device/ServerBase.cs delete mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockCompiler.cs diff --git a/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs b/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs index a8b65315..ee9addf2 100644 --- a/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs +++ b/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs @@ -164,6 +164,14 @@ public static void Main(string[] args) Console.WriteLine("└──────────────────────────────────────────┘\n"); RunAssemblyCompilationTest(reverseConverter, parser, sp); } + + if (ShouldRunTest("M")) + { + Console.WriteLine("\n┌──────────────────────────────────────────┐"); + Console.WriteLine("│ Test M: Cross-Device Plugin Call │"); + Console.WriteLine("└──────────────────────────────────────────┘\n"); + RunCrossDevicePluginCallTest(converter, reverseConverter, parser, sp); + } } private static void ParseArgs(string[] args) @@ -892,4 +900,210 @@ private static string GetNestedLoopScript() => @"#ConstBlock // ────────────────────────────────────────────── private static string GetSingleStatementScript() => @"#MainBlock Print(""Hello, World!"");"; + + // ────────────────────────────────────────────── + // Test M: Cross-Device Plugin Call + // ────────────────────────────────────────────── + private static void RunCrossDevicePluginCallTest( + BlockScriptToBlueprintConverter forwardConverter, + IBlueprintToBlockScriptConverter reverseConverter, + IBlockScriptParser parser, + System.IServiceProvider sp) + { + var sourceCode = GetCrossDeviceScript(); + bool allPassed = true; + + // ── Phase 1: Forward Conversion ── + Console.WriteLine("[Test M] Phase 1: Forward Conversion (Script → Blueprint)"); + try + { + var blueprint = forwardConverter.Convert(sourceCode, new List()); + + // ── Debug: Dump formatted script ── + if (forwardConverter.LastContext?.FormattedScript != null) + { + Console.WriteLine(" ── Formatted Script (Test M debug) ──"); + var formatted = forwardConverter.LastContext.FormattedScript; + foreach (var block in formatted.Blocks) + { + Console.WriteLine($" #Block {block.Name}"); + foreach (var stmt in block.Statements) + { + var args = stmt.Arguments != null ? string.Join(", ", stmt.Arguments) : ""; + Console.WriteLine($" [{stmt.Kind}] {stmt.OriginalExpression}"); + Console.WriteLine($" PubVarTarget={stmt.PubVarTarget} Func={stmt.FunctionName} Args=[{args}]"); + } + } + Console.WriteLine(" ── End Formatted Script ──\n"); + } + + // Find the CallNode with TargetDevice + var callNodes = blueprint.Nodes.OfType().ToList(); + Console.WriteLine($" Found {callNodes.Count} CallNode(s)"); + Console.WriteLine($" All nodes: {blueprint.Nodes.Count}, types: {string.Join(", ", blueprint.Nodes.Select(n => n.NodeType.ToString()).Distinct())}"); + + var crossDeviceCall = callNodes.FirstOrDefault(n => + !string.IsNullOrEmpty(n.TargetDevice)); + Console.WriteLine($" Cross-device CallNode: {crossDeviceCall?.Name}"); + Console.WriteLine($" TargetDevice: {crossDeviceCall?.TargetDevice}"); + Console.WriteLine($" PluginName: {crossDeviceCall?.PluginName}"); + Console.WriteLine($" FunctionName: {crossDeviceCall?.FunctionName}"); + + bool phase1pass = + crossDeviceCall != null && + crossDeviceCall.TargetDevice == "DeviceB" && + crossDeviceCall.PluginName == "WeatherPlugin" && + crossDeviceCall.FunctionName == "GetTemperature"; + + Console.WriteLine($"[Test M] Phase 1: {(phase1pass ? "PASS" : "FAIL")}"); + if (!phase1pass) allPassed = false; + } + catch (Exception ex) + { + Console.WriteLine($"[Test M] Phase 1 FAILED: {ex.Message}"); + allPassed = false; + } + + // ── Phase 2: Reverse Conversion ── + Console.WriteLine("\n[Test M] Phase 2: Reverse Conversion (Blueprint → Script)"); + try + { + var blueprint = forwardConverter.Convert(sourceCode, new List()); + var expandedScript = reverseConverter.Convert(blueprint); + + Console.WriteLine($" Expanded script length: {expandedScript.Length} chars"); + Console.WriteLine($" ── Expanded Script ──"); + Console.WriteLine(expandedScript); + Console.WriteLine($" ── End ──"); + + bool containsPluginCallWithTarget = + expandedScript.Contains("PluginCallWithTarget"); + bool containsTargetDevice = + expandedScript.Contains("DeviceB"); + + Console.WriteLine($" Contains PluginCallWithTarget: {containsPluginCallWithTarget}"); + Console.WriteLine($" Contains TargetDevice 'DeviceB': {containsTargetDevice}"); + + bool phase2pass = containsPluginCallWithTarget && containsTargetDevice; + Console.WriteLine($"[Test M] Phase 2: {(phase2pass ? "PASS" : "FAIL")}"); + if (!phase2pass) allPassed = false; + } + catch (Exception ex) + { + Console.WriteLine($"[Test M] Phase 2 FAILED: {ex.Message}"); + allPassed = false; + } + + // ── Phase 3: Assembly Compilation + Execution with Mock ── + Console.WriteLine("\n[Test M] Phase 3: Assembly Compilation + Mock Execution"); + try + { + var blueprint = forwardConverter.Convert(sourceCode, new List()); + var expandedScript = reverseConverter.Convert(blueprint); + + var parseResult = parser.Parse(expandedScript); + if (!parseResult.IsSuccess || parseResult.Script == null) + { + Console.WriteLine($"[Test M] Phase 3 FAILED: Parse error: {parseResult.ErrorMessage}"); + allPassed = false; + } + else + { + parseResult.Script.HelperFunctions = GetExecutionHelpers(); + + // Compile to assembly + var compiler = new ScriptAssemblyCompiler(); + var compiled = compiler.CompileScript(parseResult.Script); + Console.WriteLine($" Assembly compilation: {(compiled != null ? "SUCCESS" : "FAILED (null)")}"); + + if (compiled == null) + { + allPassed = false; + } + else + { + // Create mock plugin manager + var mockManager = new MockCrossDevicePluginManager(); + + // Execute via assembly - pass mockManager to globals constructor + var output = new List(); + var globals = new BlockScriptExecutionGlobals( + new BlockScopeManager(), output, mockManager); + globals.ResetRunState(); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + compiled.Run(globals, cts.Token); + + Console.WriteLine($" Assembly execution completed"); + Console.WriteLine($" Output ({output.Count} lines): [{string.Join(", ", output)}]"); + Console.WriteLine($" Recorded calls: {mockManager.RecordedCalls.Count}"); + + foreach (var call in mockManager.RecordedCalls) + { + Console.WriteLine($" {call.PluginName}.{call.MethodName}@{call.TargetDevice}"); + Console.WriteLine($" Parameters: [{string.Join(", ", call.Parameters.Select(p => p?.ToString() ?? "null"))}]"); + } + + bool phase3pass = + mockManager.RecordedCalls.Count > 0 && + mockManager.RecordedCalls.Any(c => + c.TargetDevice == "DeviceB" && + c.PluginName == "WeatherPlugin" && + c.MethodName == "GetTemperature" && + c.Parameters.Length == 1 && + (int)c.Parameters[0]! == 42); + + Console.WriteLine($"[Test M] Phase 3: {(phase3pass ? "PASS" : "FAIL")}"); + if (!phase3pass) allPassed = false; + } + } + } + catch (Exception ex) + { + Console.WriteLine($"[Test M] Phase 3 FAILED: {ex.Message}"); + Console.WriteLine($" {ex.StackTrace}"); + allPassed = false; + } + + Console.WriteLine($"\n[Test M] {(allPassed ? "PASS - All phases passed!" : "FAIL - See above for details")}"); + } + + // ────────────────────────────────────────────── + // Cross-Device BlockScript Source + // ────────────────────────────────────────────── + private static string GetCrossDeviceScript() => @"#ConstBlock +int cityId = 42; + +#PubVarBlock +int tempResult; +string textResult; + +#MainBlock +Print(""Starting cross-device call""); +tempResult = PluginCallWithTarget(""WeatherPlugin"", ""GetTemperature"", ""DeviceB"", cityId); +Print(tempResult); +Print(""Cross-device call done"");"; + + // ────────────────────────────────────────────── + // MockCrossDevicePluginManager for Test M + // ────────────────────────────────────────────── + internal class MockCrossDevicePluginManager : IPluginManager + { + public List RecordedCalls { get; } = new(); + + public T Call(PluginCallInfo callInfo) + { + RecordedCalls.Add(callInfo); + Console.WriteLine($"[Mock] PluginCall: {callInfo.PluginName}.{callInfo.MethodName}@{callInfo.TargetDevice}"); + // Return typed mock results + if (typeof(T) == typeof(int)) return (T)(object)42; + if (typeof(T) == typeof(string)) return (T)(object)$"MockResult_{callInfo.MethodName}"; + if (typeof(T) == typeof(double)) return (T)(object)25.5; + return default!; + } + + public void Call(PluginCallInfo callInfo) => RecordedCalls.Add(callInfo); + public bool IsPluginExists(string name) => true; + public bool IsMethodExists(string plugin, string method) => true; + } } diff --git a/KitX Clients/KitX Core/KitX.Core/Activity/ActivityManager.cs b/KitX Clients/KitX Core/KitX.Core/Activity/ActivityManager.cs index f29ea655..e45abcb9 100644 --- a/KitX Clients/KitX Core/KitX.Core/Activity/ActivityManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/Activity/ActivityManager.cs @@ -9,6 +9,8 @@ using KitX.Core.Event; using LiteDB; using KitX.Core.Tasks; +using KitX.Core.DI; +using Serilog; namespace KitX.Core.Activity; @@ -18,13 +20,29 @@ namespace KitX.Core.Activity; /// public class ActivityManager : IActivityService { - private static ActivityManager? _instance; - private static readonly object _activityRecordLock = new(); + /// + /// Gets the singleton instance (resolves from ServiceHost when available). + /// Internal code should use constructor injection instead. + /// + public static ActivityManager Instance + { + get + { + if (ServiceHost.IsInitialized) + return (ActivityManager)ServiceHost.GetRequiredService(); + Log.Error("[ActivityManager] Instance: ServiceHost not initialized! Returning orphan instance — " + + "this indicates a DI initialization order bug. Use ServiceHost/constructor injection instead."); + return new ActivityManager(); + } + } /// - /// Gets the singleton instance + /// Kept for backward compatibility — ServiceHost is now the single source of truth. /// - internal static ActivityManager Instance => _instance ??= new(); + [Obsolete("ServiceHost is now the single source of truth. This method is a no-op.")] + internal static void SetServiceProvider(IServiceProvider? sp) { /* no-op */ } + + private static readonly object _activityRecordLock = new(); private static LiteDatabase? _activitiesDatabase; @@ -50,9 +68,9 @@ public static LiteDatabase? ActivitiesDatabase public event EventHandler? ActivitiesUpdated; /// - /// Private constructor + /// Creates a new activity manager /// - private ActivityManager() { } + public ActivityManager() { } /// /// Reads activities from the database (static method for backward compatibility) diff --git a/KitX Clients/KitX Core/KitX.Core/Announcement/AnnouncementManager.cs b/KitX Clients/KitX Core/KitX.Core/Announcement/AnnouncementManager.cs index d0046602..86bfc40c 100644 --- a/KitX Clients/KitX Core/KitX.Core/Announcement/AnnouncementManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/Announcement/AnnouncementManager.cs @@ -10,6 +10,7 @@ using System.Text; using System.Text.Json; using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; namespace KitX.Core.Announcement; @@ -19,12 +20,27 @@ namespace KitX.Core.Announcement; /// public class AnnouncementManager : IAnnouncementService { - private static AnnouncementManager? _instance; + /// + /// Gets the singleton instance (resolves from ServiceHost when available). + /// Internal code should use constructor injection instead. + /// + public static AnnouncementManager Instance + { + get + { + if (DI.ServiceHost.IsInitialized) + return (AnnouncementManager)DI.ServiceHost.GetRequiredService(); + Log.Error("[AnnouncementManager] Instance: ServiceHost not initialized! Returning orphan instance — " + + "this indicates a DI initialization order bug. Use ServiceHost/constructor injection instead."); + return new AnnouncementManager(); + } + } /// - /// Gets the singleton instance + /// Kept for backward compatibility — ServiceHost is now the single source of truth. /// - internal static AnnouncementManager Instance => _instance ??= new(); + [Obsolete("ServiceHost is now the single source of truth. This method is a no-op.")] + internal static void SetServiceProvider(IServiceProvider? sp) { /* no-op */ } private readonly HashSet _acceptedAnnouncementIds = new(); private readonly JsonSerializerOptions _serializerOptions = new() @@ -38,7 +54,9 @@ public class AnnouncementManager : IAnnouncementService /// /// Gets the announcement configuration /// - public IAnnouncementConfig AnnouncementConfig => ConfigManager.Instance.TypedAnnouncementConfig; + public IAnnouncementConfig AnnouncementConfig => + (_configService as ConfigManager)?.TypedAnnouncementConfig + ?? throw new InvalidOperationException("IConfigService not injected or not ConfigManager"); /// /// Event raised when new announcements are available @@ -52,9 +70,9 @@ public class AnnouncementManager : IAnnouncementService public event EventHandler? AnnouncementError; /// - /// Private constructor + /// Creates a new announcement manager /// - private AnnouncementManager() + public AnnouncementManager() { LoadAcceptedIds(); } @@ -89,9 +107,8 @@ public async Task> CheckNewAnnouncementsAsync() } else { - // Fallback to legacy config if DI not available - apiServer = ConfigManager.Instance.AppConfig.Web.ApiServer; - apiPath = ConfigManager.Instance.AppConfig.Web.ApiPath; + // Cannot proceed without config service + return Array.Empty(); } var linkBase = $"https://{apiServer}{apiPath}"; @@ -209,93 +226,20 @@ private void SaveAcceptedIds() // TODO: Save to config file } + /// + /// Checks for new announcements (legacy static method — only works when DI is not available). + /// Prefer using CheckNewAnnouncementsAsync() with proper DI. + /// + [Obsolete("Use instance method CheckNewAnnouncementsAsync with DI instead")] public static async System.Threading.Tasks.Task CheckNewAnnouncements() { const string location = $"{nameof(AnnouncementManager)}.{nameof(CheckNewAnnouncements)}"; - // Use the singleton instance which now has IConfigService injected - var instance = Instance; - - string apiServer; - string apiPath; - string appLanguage; - - if (instance._configService != null) - { - apiServer = instance._configService.AppConfig.Web?.ApiServer ?? ConfigManager.Instance.AppConfig.Web.ApiServer; - apiPath = instance._configService.AppConfig.Web?.ApiPath ?? ConfigManager.Instance.AppConfig.Web.ApiPath; - appLanguage = instance._configService.AppConfig.App?.AppLanguage ?? ConfigManager.Instance.AppConfig.App.AppLanguage; - } - else - { - // Fallback to legacy config - apiServer = ConfigManager.Instance.AppConfig.Web.ApiServer; - apiPath = ConfigManager.Instance.AppConfig.Web.ApiPath; - appLanguage = ConfigManager.Instance.AppConfig.App.AppLanguage; - } - - var linkBase = new StringBuilder().Append("https://").Append(apiServer).Append(apiPath).ToString(); - - var link = new StringBuilder().Append(linkBase).Append(ConstantTable.ApiGetAnnouncements).ToString(); - - try - { - using var client = new HttpClient(); - - client.DefaultRequestHeaders.Accept.Clear(); - - var msg = await client.GetStringAsync(link); - - var list = JsonSerializer.Deserialize>(msg); - - // Get accepted announcement IDs from config - var accepted = ConfigManager.Instance.AnnouncementConfig.Accepted; - - if (list is null) - return; - - var unreads = (from item in list where !accepted.Contains(item) select DateTime.Parse(item)).ToList(); - - var src = new Dictionary(); - - foreach (var item in unreads) - { - var apiLink = new StringBuilder() - .Append($"{linkBase}{ConstantTable.ApiGetAnnouncement}") - .Append('?') - .Append($"lang={appLanguage}") - .Append('&') - .Append($"date={item:yyyy-MM-dd HH-mm}") - .ToString(); - - var md = JsonSerializer.Deserialize(await client.GetStringAsync(apiLink)); - - if (md is not null) - src.Add(item.ToString("yyyy-MM-dd HH:mm"), md); - } - - if (unreads.Count > 0) - { - // Trigger event instead of directly showing UI - // UI layer should subscribe to this event - Instance.NewAnnouncementsAvailable?.Invoke(Instance, new NewAnnouncementsEventArgs - { - AnnouncementsDict = src - }); - } - } - catch (Exception ex) - { - Log.Error(ex, $"In {location}: {ex.Message}"); - - // Trigger error event instead of showing MessageBox - // UI layer should subscribe to this event - Instance.AnnouncementError?.Invoke(Instance, new AnnouncementErrorEventArgs - { - ErrorMessage = ex.Message, - StackTrace = ex.StackTrace ?? string.Empty - }); - } + // This static method cannot work without the Instance. + // Kept for source compatibility only — will throw. + Log.Warning("[AnnouncementManager] Static CheckNewAnnouncements called — this is deprecated and will be removed."); + throw new NotSupportedException( + "AnnouncementManager.CheckNewAnnouncements is obsolete. Use DI-injected instance."); } /// diff --git a/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigManager.cs b/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigManager.cs index e17739ef..2b9caee5 100644 --- a/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigManager.cs @@ -1,7 +1,8 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using KitX.Core.Contract.Configuration; +using Microsoft.Extensions.DependencyInjection; using Serilog; namespace KitX.Core.Configuration; @@ -15,9 +16,21 @@ public class ConfigManager : IConfigService, IDisposable private static ConfigManager? _instance; /// - /// Gets the singleton instance + /// Gets the singleton instance. + /// Uses static instance to maintain singleton behavior. /// - public static ConfigManager Instance => _instance ??= new(); + public static ConfigManager Instance => _instance ??= new ConfigManager(); + + /// + /// Kept for backward compatibility — ServiceHost is now the single source of truth. + /// ConfigManager uses its own _instance field for pre-DI initialization. + /// + [Obsolete("ServiceHost is now the single source of truth. This method is a no-op.")] + internal static void SetServiceProvider(IServiceProvider? sp) + { + // ConfigManager uses _instance field for singleton, not _serviceProvider. + // ServiceHost is now the single source of truth for DI resolution. + } private string? _configLocation; @@ -79,10 +92,11 @@ public class ConfigManager : IConfigService, IDisposable public SecurityConfig TypedSecurityConfig => (SecurityConfig)SecurityConfig; /// - /// Private constructor + /// Creates a new configuration manager /// - private ConfigManager() + public ConfigManager() { + Log.Debug($"[ConfigManager] Constructor called, Instance hash: {GetHashCode()}"); _loader = new ConfigLoader(); _saver = new ConfigSaver(); } @@ -94,6 +108,7 @@ private ConfigManager() /// The config manager instance public ConfigManager SetLocation(string location) { + Log.Debug($"[ConfigManager] SetLocation called on instance {GetHashCode()} with location: {location}"); _configLocation = Path.GetFullPath(location); if (!Directory.Exists(_configLocation)) @@ -101,6 +116,7 @@ public ConfigManager SetLocation(string location) Directory.CreateDirectory(_configLocation); } + Log.Debug($"[ConfigManager] _configLocation set to: {_configLocation} on instance {GetHashCode()}"); return this; } @@ -109,8 +125,11 @@ public ConfigManager SetLocation(string location) /// public void Load() { + Log.Debug($"[ConfigManager] Load() called on instance {GetHashCode()}, _configLocation currently: {_configLocation}"); + if (string.IsNullOrEmpty(_configLocation)) { + Log.Debug($"[ConfigManager] _configLocation is null/empty, setting default location"); SetLocation("./Config/"); } @@ -128,6 +147,8 @@ public void Load() RegisterFileWatcher("PluginsConfig.json"); RegisterFileWatcher("SecurityConfig.json"); } + + Log.Debug($"[ConfigManager] Load() completed on instance {GetHashCode()}"); } /// @@ -233,6 +254,16 @@ public void DecreaseExceptCount(string watcherName, int count = 1) /// public void SaveAll() { + Log.Debug($"[ConfigManager] SaveAll() called on instance {GetHashCode()}, _configLocation: {_configLocation}"); + + if (string.IsNullOrEmpty(_configLocation)) + { + Log.Error($"[ConfigManager] SaveAll() called with null _configLocation on instance {GetHashCode()}!"); + // Fallback: set location before saving + SetLocation("./Config/"); + Log.Debug($"[ConfigManager] Emergency SetLocation called, _configLocation now: {_configLocation}"); + } + var watcherName = "ConfigFileWatcher_AppConfig"; IncreaseExceptCount(watcherName, 2); _saver.Save(AppConfig, _configLocation!, "AppConfig.json"); diff --git a/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs b/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs index 229d8d5d..cb08a5a7 100644 --- a/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs +++ b/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs @@ -30,7 +30,7 @@ namespace KitX.Core.DI; /// -/// Extension methods for configuring KitX Core services in the DI container +/// Extension methods for configuring KitX Core services in the dependency injection container /// public static class CoreServiceCollectionExtensions { @@ -48,89 +48,99 @@ public static IServiceCollection AddCoreServices(this IServiceCollection service // Configuration Services Log.Information("Registering IConfigService..."); - services.AddSingleton(ConfigManager.Instance); + services.AddSingleton(sp => ConfigManager.Instance); // Security Services Log.Information("Registering IDeviceKeyService and IEncryptionService..."); - services.AddSingleton(SecurityManager.Instance); - services.AddSingleton(SecurityManager.Instance); + services.AddSingleton(); + services.AddSingleton(); // Plugin Services Log.Information("Registering IPluginService..."); - services.AddSingleton(PluginsManager.Instance); + services.AddSingleton(); // Workflow Services Log.Information("Registering workflow services..."); // Individual service implementations (WorkflowScriptService facade used for backward compatibility) - services.AddSingleton(sp => WorkflowScriptService.BlockScriptServiceInstance); + // IBlockScriptService is created via factory to inject RealPluginManager from DI + services.AddSingleton(provider => + { + var state = WorkflowScriptService.RuntimeState; + var rpm = provider.GetRequiredService(); + var service = new BlockScriptServiceImpl(state, rpm); + Log.Information("[DI] IBlockScriptService created with RealPluginManager. HashCode: {HashCode}", rpm.GetHashCode()); + return service; + }); services.AddSingleton(sp => WorkflowScriptService.PluginServiceInstance); services.AddSingleton(sp => WorkflowScriptService.ScriptExecutionServiceInstance); services.AddSingleton(sp => WorkflowScriptService.ManagementServiceInstance); // Activity Services Log.Information("Registering IActivityService..."); - services.AddSingleton(ActivityManager.Instance); + services.AddSingleton(); // Statistics Services Log.Information("Registering IStatisticsService..."); - services.AddSingleton(StatisticsManager.Instance); + services.AddSingleton(); // Task Services Log.Information("Registering ITasksService..."); - services.AddSingleton(TasksManager.Instance); + services.AddSingleton(); // File Watcher Services Log.Information("Registering IFileWatcherService..."); - services.AddSingleton(FileWatcherManager.Instance); + services.AddSingleton(); // Hotkey Services Log.Information("Registering IKeyHookService..."); - services.AddSingleton(KeyHookManager.Instance); + services.AddSingleton(); // Event Services Log.Information("Registering IEventService..."); - services.AddSingleton(EventService.Instance); + services.AddSingleton(); // Phase 5: Device and Network Services Log.Information("Registering IDeviceDiscoveryService..."); - services.AddSingleton(DevicesDiscoveryServer.Instance); + services.AddSingleton(); Log.Information("Registering IDeviceServer..."); - services.AddSingleton(DevicesServer.Instance); + services.AddSingleton(); + + Log.Information("Registering IDevicesOrganizer..."); + services.AddSingleton(); Log.Information("Registering IPluginServer..."); - services.AddSingleton(PluginsServer.Instance); + services.AddSingleton(); // Phase 5: Device HTTP Client (for cross-device plugin invocation) Log.Information("Registering IDeviceHttpClient..."); services.AddSingleton(); - // Phase 5: Announcement Service - Log.Information("Registering IAnnouncementService..."); - services.AddSingleton(provider => + // RealPluginManager must be registered as singleton so that PluginsServer and WorkflowScriptService + // use the same instance. This ensures plugin connection events are properly received. + Log.Information("Registering RealPluginManager..."); + services.AddSingleton(provider => { - var configService = provider.GetService(); - var service = configService != null - ? new AnnouncementManager(configService) - : AnnouncementManager.Instance; - return service; + var pluginsServer = provider.GetRequiredService() as PluginsServer; + if (pluginsServer == null) + throw new InvalidOperationException("IPluginServer must be registered as PluginsServer"); + return new RealPluginManager(pluginsServer, provider.GetRequiredService()); }); + // Pre-resolve RealPluginManager to ensure it's initialized before PluginsServer.Run() is called. + // This is done in InitializeCoreServices() after the ServiceProvider is built. + + // Phase 5: Announcement Service + Log.Information("Registering IAnnouncementService..."); + services.AddSingleton(); + // KCS File Services Log.Information("Registering IKcsFileService..."); - services.AddSingleton(provider => - { - var service = new KcsFileService(); - return service; - }); + services.AddSingleton(); // Main Program Analyzer Log.Information("Registering IMainProgramAnalyzer..."); - services.AddSingleton(provider => - { - var service = new MainProgramAnalyzer(); - return service; - }); + services.AddSingleton(); // Block Script Services Log.Information("Registering IBlockScriptParser..."); @@ -145,26 +155,22 @@ public static IServiceCollection AddCoreServices(this IServiceCollection service services.AddSingleton(provider => { var service = new KitX.Core.Workflow.BlockScripting.BlockScriptExecutor(); - // Wire up plugin manager if PluginsServer is available + // Resolve RealPluginManager from DI to ensure same instance try { - var pluginManager = new KitX.Core.Workflow.RealPluginManager( - KitX.Core.Device.PluginsServer.Instance); + var pluginManager = provider.GetRequiredService(); service.SetPluginManager(pluginManager); + Log.Information("[DI] IBlockScriptExecutor: RealPluginManager HashCode = {HashCode}", pluginManager.GetHashCode()); } catch (Exception ex) { - Log.Warning(ex, "Could not initialize BlockScriptExecutor with plugin manager"); + Log.Error(ex, "[DI] Could not initialize BlockScriptExecutor with RealPluginManager from DI container"); } return service; }); Log.Information("Registering IBlockScopeManager..."); - services.AddSingleton(provider => - { - var service = new KitX.Core.Workflow.BlockScripting.BlockScopeManager(); - return service; - }); + services.AddSingleton(); // Blueprint Sub-services (must be registered before IBlueprintService) Log.Information("Registering Blueprint sub-services..."); @@ -209,13 +215,65 @@ public static IServiceCollection AddCoreServices(this IServiceCollection service // Workflow Storage Service Log.Information("Registering IWorkflowStorageService..."); - services.AddSingleton(WorkflowStorageService.Instance); + services.AddSingleton(); // Trigger Manager Log.Information("Registering TriggerManager..."); - services.AddSingleton(TriggerManager.Instance); + services.AddSingleton(); + + // IMPORTANT: Do NOT call BuildServiceProvider() here. + // The caller (App.InitializeServiceProvider) is responsible for building the single + // IServiceProvider and passing it to ServiceHost.Initialize() and InitializeCoreServices(). Log.Information("AddCoreServices completed."); return services; } + + /// + /// Initializes core services after the ServiceProvider has been built. + /// All .Instance properties now resolve from ServiceHost directly — this method + /// only pre-resolves singletons and initializes TriggerManager subscriptions. + /// Must be called exactly once after BuildServiceProvider(). + /// + /// The single IServiceProvider instance + [Obsolete("All .Instance properties now resolve from ServiceHost directly. Only pre-resolves RealPluginManager and initializes TriggerManager.")] + public static void InitializeCoreServices(IServiceProvider provider) + { + Log.Information("InitializeCoreServices called (ServiceHost is the single source of truth)."); + + // All SetServiceProvider() calls are now no-ops. + // Keeping them for backward compatibility but they do nothing. +#pragma warning disable CS0618 // Suppress Obsolete warnings for SetServiceProvider calls + ConfigManager.SetServiceProvider(provider); + SecurityManager.SetServiceProvider(provider); + EventService.SetServiceProvider(provider); + PluginsManager.SetServiceProvider(provider); + ActivityManager.SetServiceProvider(provider); + StatisticsManager.SetServiceProvider(provider); + TasksManager.SetServiceProvider(provider); + FileWatcherManager.SetServiceProvider(provider); + KeyHookManager.SetServiceProvider(provider); + DevicesServer.SetServiceProvider(provider); + DevicesDiscoveryServer.SetServiceProvider(provider); + PluginsServer.SetServiceProvider(provider); + AnnouncementManager.SetServiceProvider(provider); + DevicesOrganizer.SetServiceProvider(provider); + TriggerManager.SetServiceProvider(provider); + WorkflowStorageService.SetServiceProvider(provider); + WorkflowScriptService.SetServiceProvider(provider); +#pragma warning restore CS0618 + + // Pre-resolve RealPluginManager to ensure it's initialized before PluginsServer.Run() is called. + Log.Information("Pre-resolving RealPluginManager to ensure single instance..."); + var rpm = provider.GetRequiredService(); + Log.Information("RealPluginManager pre-resolved. HashCode: {HashCode}", rpm.GetHashCode()); + + // Initialize TriggerManager from persisted workflow configurations + // so that trigger subscriptions are registered before plugins connect. + Log.Information("Initializing TriggerManager from persisted workflows..."); + var triggerManager = provider.GetRequiredService(); + triggerManager.InitializeFromPersistedWorkflows(); + + Log.Information("InitializeCoreServices completed."); + } } diff --git a/KitX Clients/KitX Core/KitX.Core/DI/ServiceHost.cs b/KitX Clients/KitX Core/KitX.Core/DI/ServiceHost.cs new file mode 100644 index 00000000..0e415349 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/DI/ServiceHost.cs @@ -0,0 +1,87 @@ +using Microsoft.Extensions.DependencyInjection; +using Serilog; + +namespace KitX.Core.DI; + +/// +/// 统一动态服务注册管理中心,持有唯一 IServiceProvider。 +/// 所有服务解析通过此类进行,确保单例一致性。 +/// +public static class ServiceHost +{ + private static IServiceProvider? _serviceProvider; + private static bool _isInitialized; + + /// + /// Gets the single IServiceProvider for the application. + /// Throws if accessed before initialization. + /// + public static IServiceProvider ServiceProvider + { + get + { + if (_serviceProvider == null) + throw new InvalidOperationException( + "ServiceHost has not been initialized. " + + "Call ServiceHost.Initialize() first."); + + return _serviceProvider; + } + } + + /// + /// Gets whether the ServiceHost has been initialized. + /// + public static bool IsInitialized => _isInitialized; + + /// + /// Initializes the ServiceHost with the single IServiceProvider. + /// This should be called exactly ONCE during application startup, + /// after building the service provider. + /// + /// The single IServiceProvider instance + public static void Initialize(IServiceProvider serviceProvider) + { + if (_isInitialized) + { + Log.Warning("[ServiceHost] Initialize called more than once. Ignoring."); + return; + } + + _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + _isInitialized = true; + + Log.Information("[ServiceHost] Initialized. ServiceProvider HashCode: {HashCode}", + serviceProvider.GetHashCode()); + } + + /// + /// Gets a required service from the DI container. + /// Throws InvalidOperationException if the service is not registered. + /// Use this for all core services that MUST be registered. + /// + public static T GetRequiredService() where T : notnull + { + return ServiceProvider.GetRequiredService(); + } + + /// + /// Gets a service from the DI container, or null if not registered. + /// Use this for optional services. + /// + public static T? GetService() where T : class + { + return ServiceProvider.GetService(); + } + + /// + /// Creates an instance of an unregistered type using constructor injection + /// from the DI container. Use this for ViewModels and other types that + /// are not explicitly registered but have constructor dependencies on + /// registered services. + /// + public static T CreateInstance() where T : class + { + return ActivatorUtilities.CreateInstance(ServiceProvider); + } +} \ No newline at end of file diff --git a/KitX Clients/KitX Core/KitX.Core/Device/DeviceCase.cs b/KitX Clients/KitX Core/KitX.Core/Device/DeviceCase.cs index 87adaf8c..ffece95a 100644 --- a/KitX Clients/KitX Core/KitX.Core/Device/DeviceCase.cs +++ b/KitX Clients/KitX Core/KitX.Core/Device/DeviceCase.cs @@ -5,6 +5,7 @@ using KitX.Core.Contract.Security; using KitX.Core.Security; using KitX.Shared.CSharp.Device; +using Microsoft.Extensions.DependencyInjection; namespace KitX.Core.Device; diff --git a/KitX Clients/KitX Core/KitX.Core/Device/DevicesDiscoveryServer.cs b/KitX Clients/KitX Core/KitX.Core/Device/DevicesDiscoveryServer.cs index b6b8fd5f..0c10a16c 100644 --- a/KitX Clients/KitX Core/KitX.Core/Device/DevicesDiscoveryServer.cs +++ b/KitX Clients/KitX Core/KitX.Core/Device/DevicesDiscoveryServer.cs @@ -1,5 +1,5 @@ using KitX.Core.Configuration; -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Net; @@ -13,20 +13,36 @@ using KitX.Core.Contract.Device; using KitX.Shared.CSharp.Device; using Serilog; +using KitX.Core.DI; namespace KitX.Core.Device; /// /// Device discovery server for UDP broadcast /// -public class DevicesDiscoveryServer : IDeviceDiscoveryService +public class DevicesDiscoveryServer : ServerBase, IDeviceDiscoveryService { - private static DevicesDiscoveryServer? _instance; + /// + /// Gets the singleton instance (resolves from ServiceHost when available). + /// Internal code should use constructor injection instead. + /// + public static DevicesDiscoveryServer Instance + { + get + { + if (ServiceHost.IsInitialized) + return (DevicesDiscoveryServer)ServiceHost.GetRequiredService(); + Log.Error("[DevicesDiscoveryServer] Instance: ServiceHost not initialized! Returning orphan instance — " + + "this indicates a DI initialization order bug. Use ServiceHost/constructor injection instead."); + return new DevicesDiscoveryServer(); + } + } /// - /// Gets the singleton instance + /// Kept for backward compatibility — ServiceHost is now the single source of truth. /// - internal static DevicesDiscoveryServer Instance => _instance ??= new(); + [Obsolete("ServiceHost is now the single source of truth. This method is a no-op.")] + internal static void SetServiceProvider(IServiceProvider? sp) { /* no-op */ } private readonly IConfigService _configService; private UdpClient? _udpSender; @@ -34,15 +50,9 @@ public class DevicesDiscoveryServer : IDeviceDiscoveryService private System.Timers.Timer? _udpSendTimer; private readonly List _supportedNetworkInterfacesIndexes = new(); private bool _disposed; - private ServerStatus _status = ServerStatus.Pending; private int _deviceInfoUpdatedTimes = 0; private int _lastTimeToOSVersionUpdated = 0; - /// - /// Gets the service status - /// - public ServerStatus Status => _status; - /// /// Gets or sets the port /// @@ -90,9 +100,9 @@ public void ConfigurePort(int port) #pragma warning restore CS0067 /// - /// Private constructor + /// Creates a new device discovery server /// - private DevicesDiscoveryServer() + public DevicesDiscoveryServer() { _configService = ConfigManager.Instance; // Will be injected via DI in production DefaultDeviceInfo = NetworkHelper.GetDeviceInfo(); @@ -107,11 +117,9 @@ private DevicesDiscoveryServer() /// The service instance public IDeviceDiscoveryService Run() { - if (_status != ServerStatus.Pending) + if (!TryStart()) return this; - _status = ServerStatus.Starting; - Initialize(); // Read configuration from IConfigService @@ -148,7 +156,7 @@ public IDeviceDiscoveryService Run() CTask.Run(MultiDevicesBroadCastSend); CTask.Run(MultiDevicesBroadCastReceive); - _status = ServerStatus.Running; + SetRunning(); return this; } @@ -158,16 +166,15 @@ public IDeviceDiscoveryService Run() /// public void Stop() { - if (_status != ServerStatus.Running) + if (!TryStop()) return; - _status = ServerStatus.Stopping; CloseDevicesDiscoveryServerRequest = true; CTask.Run(async () => { await CTask.Delay(1000); // Wait for threads to finish - _status = ServerStatus.Pending; + SetPending(); }); } @@ -383,12 +390,12 @@ private void MultiDevicesBroadCastReceive() } } - _status = ServerStatus.Pending; + SetPending(); } catch (Exception e) { Log.Error(e, $"In {location}: {e.Message}"); - _status = ServerStatus.Errored; + SetErrored(e, nameof(DevicesDiscoveryServer)); } await CTask.Run(() => Stop()); diff --git a/KitX Clients/KitX Core/KitX.Core/Device/DevicesOrganizer.cs b/KitX Clients/KitX Core/KitX.Core/Device/DevicesOrganizer.cs index c317df02..e23d2d19 100644 --- a/KitX Clients/KitX Core/KitX.Core/Device/DevicesOrganizer.cs +++ b/KitX Clients/KitX Core/KitX.Core/Device/DevicesOrganizer.cs @@ -1,4 +1,4 @@ -using KitX.Core.Configuration; +using KitX.Core.Configuration; using System; using System.Collections.Generic; using System.Text; @@ -10,6 +10,7 @@ using KitX.Shared.CSharp.Device; using Serilog; using Timer = System.Timers.Timer; +using KitX.Core.DI; namespace KitX.Core.Device; @@ -19,12 +20,33 @@ namespace KitX.Core.Device; /// public class DevicesOrganizer : IDevicesOrganizer { - private static DevicesOrganizer? _instance; + /// + /// Gets the singleton instance (resolves from ServiceHost when available). + /// Internal code should use constructor injection instead. + /// + public static DevicesOrganizer Instance + { + get + { + if (ServiceHost.IsInitialized) + return ServiceHost.GetRequiredService(); + Log.Error("[DevicesOrganizer] Instance: ServiceHost not initialized! Returning orphan instance — " + + "this indicates a DI initialization order bug. Use ServiceHost/constructor injection instead."); + return new DevicesOrganizer(); + } + } + + /// + /// Kept for backward compatibility — ServiceHost is now the single source of truth. + /// + [Obsolete("ServiceHost is now the single source of truth. This method is a no-op.")] + internal static void SetServiceProvider(IServiceProvider? sp) { /* no-op */ } /// - /// Gets the singleton instance + /// Runs the devices organizer (ensures initialization via singleton access) /// - internal static DevicesOrganizer Instance => _instance ??= new(); + /// The singleton instance + public static DevicesOrganizer Run() => Instance; private readonly IConfigService _configService; private readonly IEventService _eventService; @@ -44,21 +66,16 @@ public class DevicesOrganizer : IDevicesOrganizer /// public event EventHandler? DeviceOffline; - private DevicesOrganizer() + /// + /// Creates a new devices organizer + /// + public DevicesOrganizer() { _configService = ConfigManager.Instance; _eventService = EventService.Instance; Initialize(); } - /// - /// Runs the devices organizer - /// - public static void Run() - { - _instance = Instance; - } - /// /// Initializes the devices organizer /// diff --git a/KitX Clients/KitX Core/KitX.Core/Device/DevicesServer.cs b/KitX Clients/KitX Core/KitX.Core/Device/DevicesServer.cs index db06f8cd..04ce7fd4 100644 --- a/KitX Clients/KitX Core/KitX.Core/Device/DevicesServer.cs +++ b/KitX Clients/KitX Core/KitX.Core/Device/DevicesServer.cs @@ -22,6 +22,7 @@ using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Hosting.Server.Features; using Microsoft.AspNetCore.Http; +using KitX.Core.DI; using Microsoft.Extensions.DependencyInjection; using Serilog; @@ -31,17 +32,31 @@ namespace KitX.Core.Device; /// Device server for HTTP API /// Phase 5: Simplified implementation using direct WebHostBuilder /// -public class DevicesServer : IDeviceServer +public class DevicesServer : ServerBase, IDeviceServer { - private static DevicesServer? _instance; + /// + /// Gets the singleton instance (resolves from ServiceHost when available). + /// Internal code should use constructor injection instead. + /// + public static DevicesServer Instance + { + get + { + if (ServiceHost.IsInitialized) + return (DevicesServer)ServiceHost.GetRequiredService(); + Log.Error("[DevicesServer] Instance: ServiceHost not initialized! Returning orphan instance — " + + "this indicates a DI initialization order bug. Use ServiceHost/constructor injection instead."); + return new DevicesServer(); + } + } /// - /// Gets the singleton instance + /// Kept for backward compatibility — ServiceHost is now the single source of truth. /// - internal static DevicesServer Instance => _instance ??= new(); + [Obsolete("ServiceHost is now the single source of truth. This method is a no-op.")] + internal static void SetServiceProvider(IServiceProvider? sp) { /* no-op */ } private readonly Dictionary _signedDeviceTokens = new(); - private ServerStatus _status = ServerStatus.Pending; private IWebHost? _host; private int? _configuredPort; @@ -71,11 +86,6 @@ public class DevicesServer : IDeviceServer /// private readonly ConcurrentDictionary> _pendingPluginResponses = new(); - /// - /// Gets the service status - /// - public ServerStatus Status => _status; - /// /// Event raised when port changes /// @@ -83,6 +93,11 @@ public class DevicesServer : IDeviceServer public event EventHandler? PortChanged; #pragma warning restore CS0067 + /// + /// Creates a new device server + /// + public DevicesServer() { } + /// /// Gets or sets the port /// @@ -103,11 +118,9 @@ public void ConfigurePort(int port) /// The server instance public IDeviceServer Run() { - if (_status != ServerStatus.Pending) + if (!TryStart()) return this; - _status = ServerStatus.Starting; - var port = _configuredPort ?? 8888; try @@ -215,12 +228,11 @@ public IDeviceServer Run() Log.Information($"DevicesServer started on port {Port}"); } - _status = ServerStatus.Running; + SetRunning(); } catch (Exception ex) { - Log.Error(ex, $"Failed to start DevicesServer: {ex.Message}"); - _status = ServerStatus.Errored; + SetErrored(ex, nameof(DevicesServer)); } }) { @@ -231,21 +243,20 @@ public IDeviceServer Run() // Wait for server to start var timeout = 0; - while (_status == ServerStatus.Starting && timeout < 50) // 5 seconds timeout + while (IsStarting && timeout < 50) // 5 seconds timeout { Thread.Sleep(100); timeout++; } - if (_status != ServerStatus.Running) + if (!IsRunning) { Log.Warning("DevicesServer start timed out or failed"); } } catch (Exception ex) { - Log.Error(ex, $"Error starting DevicesServer: {ex.Message}"); - _status = ServerStatus.Errored; + SetErrored(ex, nameof(DevicesServer)); } return this; @@ -256,11 +267,9 @@ public IDeviceServer Run() /// public void Stop() { - if (_status != ServerStatus.Running) + if (!TryStop()) return; - _status = ServerStatus.Stopping; - try { if (_host is not null) @@ -271,12 +280,11 @@ public void Stop() } Log.Information("DevicesServer stopped"); - _status = ServerStatus.Pending; + SetPending(); } catch (Exception ex) { - Log.Error(ex, $"Error stopping DevicesServer: {ex.Message}"); - _status = ServerStatus.Errored; + SetErrored(ex, nameof(DevicesServer)); } } diff --git a/KitX Clients/KitX Core/KitX.Core/Device/PluginConnection.cs b/KitX Clients/KitX Core/KitX.Core/Device/PluginConnection.cs index 6cfe9d28..8f979178 100644 --- a/KitX Clients/KitX Core/KitX.Core/Device/PluginConnection.cs +++ b/KitX Clients/KitX Core/KitX.Core/Device/PluginConnection.cs @@ -55,9 +55,7 @@ public class PluginConnection : IPluginConnection, IPluginConnector /// /// Event raised when plugin reports status (IPluginConnector implementation) /// -#pragma warning disable CS0067 public event EventHandler? StatusReport; -#pragma warning restore CS0067 /// /// Constructor @@ -78,6 +76,11 @@ public void Initialize() _connection.OnOpen = () => { _status = ServerStatus.Running; + StatusReport?.Invoke(this, new PluginStatusReportEventArgs + { + ConnectionId = ConnectionId!, + Status = ServerStatus.Running.ToString() + }); }; _connection.OnMessage = message => @@ -114,12 +117,22 @@ public void Initialize() _connection.OnClose = () => { _status = ServerStatus.Pending; + StatusReport?.Invoke(this, new PluginStatusReportEventArgs + { + ConnectionId = ConnectionId!, + Status = ServerStatus.Pending.ToString() + }); Closed?.Invoke(this, EventArgs.Empty); }; _connection.OnError = ex => { _status = ServerStatus.Errored; + StatusReport?.Invoke(this, new PluginStatusReportEventArgs + { + ConnectionId = ConnectionId!, + Status = ServerStatus.Errored.ToString() + }); Serilog.Log.Error(ex, $"PluginConnection error for {ConnectionId}, triggering Closed event"); // Also trigger Closed event when error occurs (e.g., remote host disconnected abruptly) diff --git a/KitX Clients/KitX Core/KitX.Core/Device/PluginsServer.cs b/KitX Clients/KitX Core/KitX.Core/Device/PluginsServer.cs index bfc3297b..28173bfb 100644 --- a/KitX Clients/KitX Core/KitX.Core/Device/PluginsServer.cs +++ b/KitX Clients/KitX Core/KitX.Core/Device/PluginsServer.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Text.Json; @@ -9,6 +9,8 @@ using KitX.Core.Contract.Plugin; using KitX.Core.Contract.Plugin.Events; using KitX.Core.Event; +using KitX.Core.DI; +using KitX.Core.Plugin; using KitX.Shared.CSharp.Plugin; using KitX.Shared.CSharp.WebCommand; using Serilog; @@ -20,18 +22,36 @@ namespace KitX.Core.Device; /// /// Plugins server for WebSocket connections /// -public class PluginsServer : IPluginServer +public class PluginsServer : ServerBase, IPluginServer { - private static PluginsServer? _instance; + /// + /// Gets the singleton instance (resolves from ServiceHost when available). + /// Internal code should use constructor injection instead. + /// + public static PluginsServer Instance + { + get + { + if (DI.ServiceHost.IsInitialized) + { + var instance = (PluginsServer)DI.ServiceHost.GetRequiredService(); + Log.Information("[PluginsServer] Instance: resolved from ServiceHost. HashCode: {HashCode}", instance.GetHashCode()); + return instance; + } + Log.Error("[PluginsServer] Instance: ServiceHost not initialized! Returning orphan instance — " + + "this indicates a DI initialization order bug. Use ServiceHost/constructor injection instead."); + return new PluginsServer(); + } + } /// - /// Gets the singleton instance + /// Kept for backward compatibility — ServiceHost is now the single source of truth. /// - internal static PluginsServer Instance => _instance ??= new(); + [Obsolete("ServiceHost is now the single source of truth. This method is a no-op.")] + internal static void SetServiceProvider(IServiceProvider? sp) { /* no-op, ServiceHost is used instead */ } private WebSocketServer? _server; private readonly List _connections = new(); - private ServerStatus _status = ServerStatus.Pending; /// /// JSON serializer options (accessible from PluginConnection) @@ -43,11 +63,6 @@ public class PluginsServer : IPluginServer PropertyNameCaseInsensitive = true, }; - /// - /// Gets the service status - /// - public ServerStatus Status => _status; - /// /// Gets or sets the port /// @@ -102,9 +117,9 @@ public class PluginsServer : IPluginServer public event EventHandler? PluginResponse; /// - /// Private constructor + /// Creates a new plugins server /// - private PluginsServer() + public PluginsServer() { } @@ -114,22 +129,9 @@ private PluginsServer() /// The server instance public IPluginServer Run() { - if (_status != ServerStatus.Pending) + if (!TryStart()) return this; - _status = ServerStatus.Starting; - - // Initialize RealPluginManager when server starts, so it can receive plugin messages - try - { - _ = new KitX.Core.Workflow.RealPluginManager(this); - Log.Information("[PluginsServer] RealPluginManager initialized for message handling"); - } - catch (Exception ex) - { - Log.Warning(ex, "[PluginsServer] Failed to initialize RealPluginManager"); - } - const int maxRetries = 10; const int startPort = 7777; int currentPort = startPort; @@ -164,8 +166,10 @@ public IPluginServer Run() return; } + Log.Information($"[PluginsServer] About to add connection {connectionId}. _connections count before: {_connections.Count}, this HashCode: {GetHashCode()}"); var connection = new PluginConnection(socket, connectionId); _connections.Add(connection); + Log.Information($"[PluginsServer] Added connection {connectionId}. _connections count after: {_connections.Count}"); // Handle connection closed connection.Closed += (sender, args) => @@ -179,13 +183,8 @@ public IPluginServer Run() { Log.Information($"[PluginsServer] Publishing PluginUnregistered for: {connection.PluginInfo.Name}"); - PluginUnregistered?.Invoke(this, new PluginUnregisteredEventArgs - { - PluginInfo = connection.PluginInfo - }); - - // Publish event via EventService - EventService.Instance.Publish(EventNames.PluginUnregistered, new PluginEventArgs + // Publish event via EventService only (public event removed to eliminate dual-invocation pattern) + EventService.Instance.Publish(EventNames.PluginUnregistered, new PluginUnregisteredEventArgs { PluginInfo = connection.PluginInfo }); @@ -193,12 +192,7 @@ public IPluginServer Run() Log.Information($"[PluginsServer] Publishing PluginDisconnected for: {connectionId}"); - PluginDisconnected?.Invoke(this, new PluginDisconnectedEventArgs - { - ConnectionId = connectionId - }); - - // Publish event via EventService + // Publish event via EventService only EventService.Instance.Publish(EventNames.PluginDisconnected, new PluginConnectionEventArgs { ConnectionId = connectionId, @@ -214,8 +208,10 @@ public IPluginServer Run() if (kwc?.Content is not null) { var cmd = System.Text.Json.JsonSerializer.Deserialize(kwc.Content); + Log.Information($"[PluginsServer] MessageReceived: cmd.Request = {cmd.Request}, expected = {KitX.Shared.CSharp.WebCommand.Infos.CommandRequestInfo.RegisterPlugin}"); if (cmd.Request == KitX.Shared.CSharp.WebCommand.Infos.CommandRequestInfo.RegisterPlugin) { + Log.Information($"[PluginsServer] Processing RegisterPlugin message"); var body = System.Text.Encoding.UTF8.GetString(cmd.Body.AsSpan(0, cmd.BodyLength).ToArray()); var pluginInfo = System.Text.Json.JsonSerializer.Deserialize(body); if (pluginInfo is not null) @@ -224,16 +220,29 @@ public IPluginServer Run() pluginInfo.Tags[nameof(PluginConnection.ConnectionId)] = connectionId; pluginInfo.Tags["JoinTime"] = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss(FF)"); connection.PluginInfo = pluginInfo; - PluginRegistered?.Invoke(this, new PluginRegisteredEventArgs + // Publish event via EventService only (removed direct Invoke to eliminate dual-invocation) + Log.Information($"[PluginsServer] Publishing PluginRegistered event for: {pluginInfo.Name}"); + EventService.Instance.Publish(EventNames.PluginRegistered, new PluginRegisteredEventArgs { PluginInfo = pluginInfo }); - // Publish event via EventService - EventService.Instance.Publish(EventNames.PluginRegistered, new PluginEventArgs + // After registration, the WebSocket is already open (OnOpen fired earlier + // but PluginInfo was null, so StatusReport was ignored). Explicitly notify + // PluginsManager that this plugin is now Running so the status light updates. + try { - PluginInfo = pluginInfo - }); + if (DI.ServiceHost.IsInitialized) + { + var pluginsManager = (Plugin.PluginsManager)DI.ServiceHost.GetRequiredService(); + pluginsManager.OnPluginStatusChanged(pluginInfo.Name, PluginStatus.Running); + } + } + catch (Exception ex) + { + Log.Warning(ex, "[PluginsServer] Failed to notify Running status after registration for {PluginName}", + pluginInfo.Name); + } } } } @@ -243,6 +252,7 @@ public IPluginServer Run() Log.Warning(ex, "[PluginsServer] Error handling plugin message"); } + // Also trigger PluginMessageReceived event for components that subscribe directly Log.Information($"[PluginsServer] Invoking PluginMessageReceived event for connection {connectionId}"); PluginMessageReceived?.Invoke(this, new PluginMessageReceivedEventArgs { @@ -254,13 +264,57 @@ public IPluginServer Run() // Forward PluginResponse events from PluginConnection to PluginsServer.PluginResponse connection.PluginResponse += (sender, args) => { - Log.Information($"[PluginsServer] Forwarding PluginResponse event, RequestId: {args.RequestId}"); - PluginResponse?.Invoke(this, args); + Log.Information($"[PluginsServer] Publishing PluginResponse event, RequestId: {args.RequestId}"); + EventService.Instance.Publish(EventNames.PluginResponse, args); + }; + + // Forward StatusReport events from PluginConnection to PluginsManager. + // This enables the status light in Dashboard to update when plugin status changes + // (e.g., from Pending to Running, or from Running to Errored). + connection.StatusReport += (sender, args) => + { + try + { + var conn = sender as PluginConnection ?? connection; + var pluginName = conn.PluginInfo?.Name; + if (pluginName is null) + { + Log.Debug("[PluginsServer] StatusReport received but plugin not yet registered, ignoring (ConnectionId: {ConnectionId})", args.ConnectionId); + return; + } + + var newStatus = args.Status switch + { + "Running" => PluginStatus.Running, + "Pending" => PluginStatus.Stopped, + "Errored" => PluginStatus.Error, + _ => PluginStatus.Unknown + }; + + Log.Information("[PluginsServer] Plugin '{PluginName}' status changed to {Status} (ConnectionId: {ConnectionId})", + pluginName, newStatus, args.ConnectionId); + + // Update PluginsManager internal state and notify UI + if (DI.ServiceHost.IsInitialized) + { + var pluginsManager = (Plugin.PluginsManager)DI.ServiceHost.GetRequiredService(); + pluginsManager.OnPluginStatusChanged(pluginName, newStatus); + } + else + { + Log.Error("[PluginsServer] Cannot forward status change: ServiceHost not initialized"); + } + } + catch (Exception ex) + { + Log.Warning(ex, "[PluginsServer] Error forwarding StatusReport to PluginsManager"); + } }; connection.Initialize(); - PluginConnected?.Invoke(this, new PluginConnectedEventArgs + // Publish event via EventService only (removed direct Invoke to eliminate dual-invocation) + EventService.Instance.Publish(EventNames.PluginConnected, new PluginConnectedEventArgs { ConnectionId = connectionId }); @@ -302,11 +356,11 @@ public IPluginServer Run() if (!serverStarted) { Log.Error($"Failed to start PluginsServer after {maxRetries} attempts"); - _status = ServerStatus.Errored; + SetErrored(null, nameof(PluginsServer)); return this; } - _status = ServerStatus.Running; + SetRunning(); return this; } @@ -346,11 +400,9 @@ public IPluginServer Run() /// public void Stop() { - if (_status != ServerStatus.Running) + if (!TryStop()) return; - _status = ServerStatus.Stopping; - try { _server?.Dispose(); @@ -364,12 +416,11 @@ public void Stop() _connections.Clear(); Log.Information("[PluginsServer] PluginsServer stopped"); - _status = ServerStatus.Pending; + SetPending(); } catch (Exception ex) { - Log.Error(ex, "[PluginsServer] Error stopping PluginsServer"); - _status = ServerStatus.Errored; + SetErrored(ex, nameof(PluginsServer)); } } @@ -388,7 +439,7 @@ await CTask.Run(() => _server = null; - _status = ServerStatus.Pending; + SetPending(); }); return this; @@ -399,4 +450,4 @@ await CTask.Run(() => /// private static Regex RegexToVerifyConnectionId() => new(@"^[{]?[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}[}]?$"); -} \ No newline at end of file +} diff --git a/KitX Clients/KitX Core/KitX.Core/Device/ServerBase.cs b/KitX Clients/KitX Core/KitX.Core/Device/ServerBase.cs new file mode 100644 index 00000000..0748966d --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Device/ServerBase.cs @@ -0,0 +1,126 @@ +using Serilog; + +namespace KitX.Core.Device; + +/// +/// Base class for server implementations, providing common status management +/// and lifecycle patterns. All three servers (DevicesServer, DevicesDiscoveryServer, +/// PluginsServer) share the same status transition pattern. +/// +/// Usage pattern: +/// +/// public class MyServer : ServerBase +/// { +/// public MyServer Run() +/// { +/// if (!TryStart()) return this; +/// +/// try +/// { +/// // startup logic +/// SetRunning(); +/// } +/// catch (Exception ex) +/// { +/// SetErrored(ex); +/// } +/// return this; +/// } +/// +/// public void Stop() +/// { +/// if (!TryStop()) return; +/// +/// try +/// { +/// // shutdown logic +/// SetPending(); +/// } +/// catch (Exception ex) +/// { +/// SetErrored(ex); +/// } +/// } +/// } +/// +/// +public abstract class ServerBase +{ + private ServerStatus _status = ServerStatus.Pending; + + /// + /// Gets the current server status + /// + public ServerStatus Status => _status; + + /// + /// Gets whether the server can start (status is Pending) + /// + protected bool IsPending => _status == ServerStatus.Pending; + + /// + /// Gets whether the server is starting + /// + protected bool IsStarting => _status == ServerStatus.Starting; + + /// + /// Gets whether the server is running + /// + protected bool IsRunning => _status == ServerStatus.Running; + + /// + /// Gets whether the server is stopping + /// + protected bool IsStopping => _status == ServerStatus.Stopping; + + /// + /// Gets whether the server is in an errored state + /// + protected bool IsErrored => _status == ServerStatus.Errored; + + /// + /// Tries to transition from Pending to Starting. Returns false if already started. + /// + /// True if transition succeeded + protected bool TryStart() + { + if (_status != ServerStatus.Pending) + return false; + _status = ServerStatus.Starting; + return true; + } + + /// + /// Transitions to Running state + /// + protected void SetRunning() => _status = ServerStatus.Running; + + /// + /// Tries to transition from Running to Stopping. Returns false if not running. + /// + /// True if transition succeeded + protected bool TryStop() + { + if (_status != ServerStatus.Running) + return false; + _status = ServerStatus.Stopping; + return true; + } + + /// + /// Transitions to Pending state (stopped cleanly) + /// + protected void SetPending() => _status = ServerStatus.Pending; + + /// + /// Transitions to Errored state and logs the exception + /// + /// The exception that caused the error + /// Context string for logging + protected void SetErrored(Exception? ex, string context) + { + _status = ServerStatus.Errored; + if (ex != null) + Log.Error(ex, $"[{context}] {ex.Message}"); + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Event/EventArgs.cs b/KitX Clients/KitX Core/KitX.Core/Event/EventArgs.cs index d855cdc6..c900f2f7 100644 --- a/KitX Clients/KitX Core/KitX.Core/Event/EventArgs.cs +++ b/KitX Clients/KitX Core/KitX.Core/Event/EventArgs.cs @@ -39,6 +39,7 @@ public class DeviceInfoEventArgs : EventArgs /// /// Event args for plugin events /// +[Obsolete("Use KitX.Core.Contract.Plugin.Events.PluginRegisteredEventArgs or PluginUnregisteredEventArgs instead.")] public class PluginEventArgs : EventArgs { /// diff --git a/KitX Clients/KitX Core/KitX.Core/Event/EventNames.cs b/KitX Clients/KitX Core/KitX.Core/Event/EventNames.cs index f26d54d2..ef7d503a 100644 --- a/KitX Clients/KitX Core/KitX.Core/Event/EventNames.cs +++ b/KitX Clients/KitX Core/KitX.Core/Event/EventNames.cs @@ -110,6 +110,16 @@ public static class EventNames /// public const string PluginUnregistered = "PluginUnregistered"; + /// + /// Plugin message received event + /// + public const string PluginMessageReceived = "PluginMessageReceived"; + + /// + /// Plugin response event (has RequestId) + /// + public const string PluginResponse = "PluginResponse"; + /// /// Workflow created event /// diff --git a/KitX Clients/KitX Core/KitX.Core/Event/EventService.cs b/KitX Clients/KitX Core/KitX.Core/Event/EventService.cs index 4181a739..8093f489 100644 --- a/KitX Clients/KitX Core/KitX.Core/Event/EventService.cs +++ b/KitX Clients/KitX Core/KitX.Core/Event/EventService.cs @@ -3,6 +3,7 @@ using System.Threading; using KitX.Core.Contract.Event; using Serilog; +using KitX.Core.DI; namespace KitX.Core.Event; @@ -11,12 +12,27 @@ namespace KitX.Core.Event; /// public class EventService : IEventService { - private static EventService? _instance; + /// + /// Gets the singleton instance (resolves from ServiceHost when available). + /// Internal code should use constructor injection instead. + /// + public static EventService Instance + { + get + { + if (ServiceHost.IsInitialized) + return (EventService)ServiceHost.GetRequiredService(); + Log.Error("[EventService] Instance: ServiceHost not initialized! Returning orphan instance — " + + "this indicates a DI initialization order bug. Use ServiceHost/constructor injection instead."); + return new EventService(); + } + } /// - /// Gets the singleton instance + /// Kept for backward compatibility — ServiceHost is now the single source of truth. /// - internal static EventService Instance => _instance ??= new(); + [Obsolete("ServiceHost is now the single source of truth. This method is a no-op.")] + internal static void SetServiceProvider(IServiceProvider? sp) { /* no-op */ } private readonly Dictionary>> _eventHandlers = new(); @@ -41,9 +57,9 @@ public class EventService : IEventService private const int MaxPublishDepth = 10; /// - /// Private constructor + /// Creates a new event service /// - private EventService() { } + public EventService() { } /// /// Subscribes to an event diff --git a/KitX Clients/KitX Core/KitX.Core/FileWatcher/FileWatcherManager.cs b/KitX Clients/KitX Core/KitX.Core/FileWatcher/FileWatcherManager.cs index 1b3e50bf..46806717 100644 --- a/KitX Clients/KitX Core/KitX.Core/FileWatcher/FileWatcherManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/FileWatcher/FileWatcherManager.cs @@ -2,6 +2,8 @@ using System.Collections.Generic; using System.IO; using KitX.Core.Contract.FileWatcher; +using KitX.Core.DI; +using Serilog; namespace KitX.Core.FileWatcher; @@ -10,19 +12,34 @@ namespace KitX.Core.FileWatcher; /// public class FileWatcherManager : IFileWatcherService { - private static FileWatcherManager? _instance; + /// + /// Gets the singleton instance (resolves from ServiceHost when available). + /// Internal code should use constructor injection instead. + /// + public static FileWatcherManager Instance + { + get + { + if (ServiceHost.IsInitialized) + return (FileWatcherManager)ServiceHost.GetRequiredService(); + Log.Error("[FileWatcherManager] Instance: ServiceHost not initialized! Returning orphan instance — " + + "this indicates a DI initialization order bug. Use ServiceHost/constructor injection instead."); + return new FileWatcherManager(); + } + } /// - /// Gets the singleton instance + /// Kept for backward compatibility — ServiceHost is now the single source of truth. /// - internal static FileWatcherManager Instance => _instance ??= new(); + [Obsolete("ServiceHost is now the single source of truth. This method is a no-op.")] + internal static void SetServiceProvider(IServiceProvider? sp) { /* no-op */ } private readonly Dictionary _watchers = new(); /// - /// Private constructor + /// Creates a new file watcher manager /// - private FileWatcherManager() { } + public FileWatcherManager() { } /// /// Registers a file watcher diff --git a/KitX Clients/KitX Core/KitX.Core/Hotkey/KeyHookManager.cs b/KitX Clients/KitX Core/KitX.Core/Hotkey/KeyHookManager.cs index 86d66b11..82ef564e 100644 --- a/KitX Clients/KitX Core/KitX.Core/Hotkey/KeyHookManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/Hotkey/KeyHookManager.cs @@ -5,6 +5,8 @@ using KitX.Core.Contract.Hotkey; using SharpHook; using SharpHook.Data; +using KitX.Core.DI; +using Serilog; namespace KitX.Core.Hotkey; @@ -13,12 +15,27 @@ namespace KitX.Core.Hotkey; /// public class KeyHookManager : IKeyHookService { - private static KeyHookManager? _instance; + /// + /// Gets the singleton instance (resolves from ServiceHost when available). + /// Internal code should use constructor injection instead. + /// + public static KeyHookManager Instance + { + get + { + if (ServiceHost.IsInitialized) + return (KeyHookManager)ServiceHost.GetRequiredService(); + Log.Error("[KeyHookManager] Instance: ServiceHost not initialized! Returning orphan instance — " + + "this indicates a DI initialization order bug. Use ServiceHost/constructor injection instead."); + return new KeyHookManager(); + } + } /// - /// Gets the singleton instance + /// Kept for backward compatibility — ServiceHost is now the single source of truth. /// - internal static KeyHookManager Instance => _instance ??= new(); + [Obsolete("ServiceHost is now the single source of truth. This method is a no-op.")] + internal static void SetServiceProvider(IServiceProvider? sp) { /* no-op */ } private const int KeysLimitation = 5; @@ -31,9 +48,9 @@ public class KeyHookManager : IKeyHookService private TaskPoolGlobalHook? _hook; /// - /// Private constructor + /// Creates a new key hook manager /// - private KeyHookManager() { } + public KeyHookManager() { } /// /// Starts the key hook diff --git a/KitX Clients/KitX Core/KitX.Core/Plugin/PluginsManager.cs b/KitX Clients/KitX Core/KitX.Core/Plugin/PluginsManager.cs index a24ec70b..01b61758 100644 --- a/KitX Clients/KitX Core/KitX.Core/Plugin/PluginsManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/Plugin/PluginsManager.cs @@ -12,6 +12,7 @@ using KitX.Shared.CSharp.Loader; using KitX.Shared.CSharp.Plugin; using Serilog; +using KitX.Core.DI; namespace KitX.Core.Plugin; @@ -20,12 +21,27 @@ namespace KitX.Core.Plugin; /// public class PluginsManager : IPluginService { - private static PluginsManager? _instance; + /// + /// Gets the singleton instance (resolves from ServiceHost when available). + /// Internal code should use constructor injection instead. + /// + public static PluginsManager Instance + { + get + { + if (ServiceHost.IsInitialized) + return (PluginsManager)ServiceHost.GetRequiredService(); + Log.Error("[PluginsManager] Instance: ServiceHost not initialized! Returning orphan instance — " + + "this indicates a DI initialization order bug. Use ServiceHost/constructor injection instead."); + return new PluginsManager(); + } + } /// - /// Gets the singleton instance + /// Kept for backward compatibility — ServiceHost is now the single source of truth. /// - internal static PluginsManager Instance => _instance ??= new(); + [Obsolete("ServiceHost is now the single source of truth. This method is a no-op.")] + internal static void SetServiceProvider(IServiceProvider? sp) { /* no-op */ } private readonly List _plugins = new(); @@ -35,9 +51,9 @@ public class PluginsManager : IPluginService public event EventHandler? PluginStatusChanged; /// - /// Private constructor + /// Creates a new plugins manager /// - private PluginsManager() + public PluginsManager() { // Load installed plugins on startup LoadInstalledPlugins(); @@ -577,6 +593,47 @@ public async Task StopPluginAsync(Guid pluginId) } } + /// + /// Called by PluginsServer when a plugin's connection status changes (Running/Pending/Errored). + /// Updates internal state and fires PluginStatusChanged event so the Dashboard UI refreshes. + /// Unlike UpdatePluginRunningState, this method handles all ServerStatus-to-PluginStatus mappings + /// including the Error state. + /// + /// The plugin name + /// The new status from the connection layer + public void OnPluginStatusChanged(string pluginName, PluginStatus newStatus) + { + try + { + var plugin = _plugins.FirstOrDefault(p => p.PluginInfo?.Name == pluginName); + if (plugin == null) + { + Log.Debug("[PluginsManager] OnPluginStatusChanged: plugin '{PluginName}' not found in installed list, ignoring", pluginName); + return; + } + + var oldStatus = plugin.IsRunning ? PluginStatus.Running : PluginStatus.Installed; + + // Update IsRunning based on connection status + plugin.IsRunning = newStatus == PluginStatus.Running; + + Log.Information("[PluginsManager] Plugin '{PluginName}' status changed: {OldStatus} -> {NewStatus}", + pluginName, oldStatus, newStatus); + + PluginStatusChanged?.Invoke(this, new PluginStatusChangedEventArgs + { + PluginId = plugin.Id, + PluginName = pluginName, + OldStatus = oldStatus, + NewStatus = newStatus + }); + } + catch (Exception ex) + { + Log.Warning(ex, "[PluginsManager] Error in OnPluginStatusChanged for plugin '{PluginName}'", pluginName); + } + } + /// /// Calls a plugin function /// diff --git a/KitX Clients/KitX Core/KitX.Core/Security/SecurityManager.cs b/KitX Clients/KitX Core/KitX.Core/Security/SecurityManager.cs index fbd3eb97..ddd38446 100644 --- a/KitX Clients/KitX Core/KitX.Core/Security/SecurityManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/Security/SecurityManager.cs @@ -9,6 +9,7 @@ using KitX.Shared.CSharp.Device; using KitX.Shared.CSharp.Security; using Serilog; +using KitX.Core.DI; namespace KitX.Core.Security; @@ -17,12 +18,27 @@ namespace KitX.Core.Security; /// public class SecurityManager : IDeviceKeyService, IEncryptionService { - private static SecurityManager? _instance; + /// + /// Gets the singleton instance (resolves from ServiceHost when available). + /// Internal code should use constructor injection instead. + /// + public static SecurityManager Instance + { + get + { + if (ServiceHost.IsInitialized) + return (SecurityManager)ServiceHost.GetRequiredService(); + Log.Error("[SecurityManager] Instance: ServiceHost not initialized! Returning orphan instance — " + + "this indicates a DI initialization order bug. Use ServiceHost/constructor injection instead."); + return new SecurityManager(); + } + } /// - /// Gets the singleton instance + /// Kept for backward compatibility — ServiceHost is now the single source of truth. /// - internal static SecurityManager Instance => _instance ??= new(); + [Obsolete("ServiceHost is now the single source of truth. This method is a no-op.")] + internal static void SetServiceProvider(IServiceProvider? sp) { /* no-op */ } private RSA? _rsaInstance; @@ -31,12 +47,17 @@ public class SecurityManager : IDeviceKeyService, IEncryptionService /// /// Reference to ConfigManager instance /// - private ConfigManager? _configManager; + private readonly ConfigManager _configManager; + + /// + /// Reference to DevicesDiscoveryServer instance + /// + private readonly DevicesDiscoveryServer? _devicesDiscoveryServer; /// /// Gets typed SecurityConfig for direct property access /// - private SecurityConfig? TypedSecurityConfig => _configManager?.TypedSecurityConfig; + private SecurityConfig? TypedSecurityConfig => _configManager.TypedSecurityConfig; /// /// Gets the local device key @@ -48,10 +69,21 @@ public DeviceKey? LocalDeviceKey } /// - /// Private constructor + /// Creates a new security manager /// - private SecurityManager() + public SecurityManager() : this(ConfigManager.Instance, null) { + } + + /// + /// Creates a new security manager with dependencies + /// + /// Configuration manager + /// Devices discovery server (optional for backward compatibility) + public SecurityManager(ConfigManager configManager, DevicesDiscoveryServer? devicesDiscoveryServer) + { + _configManager = configManager ?? ConfigManager.Instance; + _devicesDiscoveryServer = devicesDiscoveryServer; Initialize(); } @@ -60,14 +92,11 @@ private SecurityManager() /// private void Initialize() { - // Get ConfigManager instance - _configManager = ConfigManager.Instance; - // Create RSA instance _rsaInstance = RSA.Create(2048); // Get current device info from DevicesDiscoveryServer (same as legacy architecture) - var defaultDeviceInfo = DevicesDiscoveryServer.Instance?.DefaultDeviceInfo; + var defaultDeviceInfo = _devicesDiscoveryServer?.DefaultDeviceInfo; var currentDevice = defaultDeviceInfo?.Device ?? new DeviceLocator { DeviceName = Environment.MachineName, diff --git a/KitX Clients/KitX Core/KitX.Core/Statistics/StatisticsManager.cs b/KitX Clients/KitX Core/KitX.Core/Statistics/StatisticsManager.cs index 667d464a..67687721 100644 --- a/KitX Clients/KitX Core/KitX.Core/Statistics/StatisticsManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/Statistics/StatisticsManager.cs @@ -4,6 +4,7 @@ using KitX.Core.Contract.Statistics; using Serilog; using STimer = System.Timers.Timer; +using KitX.Core.DI; namespace KitX.Core.Statistics; @@ -12,28 +13,43 @@ namespace KitX.Core.Statistics; /// public class StatisticsManager : IStatisticsService { - private static StatisticsManager? _instance; - /// - /// Gets the singleton instance + /// Gets the singleton instance (resolves from ServiceHost when available). + /// Internal code should use constructor injection instead. /// - internal static StatisticsManager Instance => _instance ??= new(); + public static StatisticsManager Instance + { + get + { + if (ServiceHost.IsInitialized) + return (StatisticsManager)ServiceHost.GetRequiredService(); + Log.Error("[StatisticsManager] Instance: ServiceHost not initialized! Returning orphan instance — " + + "this indicates a DI initialization order bug. Use ServiceHost/constructor injection instead."); + return new StatisticsManager(); + } + } /// - /// Gets use statistics (static property for backward compatibility) + /// Kept for backward compatibility — ServiceHost is now the single source of truth. /// - public static Dictionary? UseStatistics => Instance._useStatistics; + [Obsolete("ServiceHost is now the single source of truth. This method is a no-op.")] + internal static void SetServiceProvider(IServiceProvider? sp) { /* no-op */ } private Dictionary? _useStatistics = []; + /// + /// Gets the raw usage statistics dictionary (for backward compatibility) + /// + public static Dictionary? UseStatistics => Instance._useStatistics; + private STimer? _timer; private bool _isRunning; /// - /// Private constructor + /// Creates a new statistics manager /// - private StatisticsManager() { } + public StatisticsManager() { } /// /// Starts statistics collection diff --git a/KitX Clients/KitX Core/KitX.Core/Tasks/TasksManager.cs b/KitX Clients/KitX Core/KitX.Core/Tasks/TasksManager.cs index 5300f349..b9cf540c 100644 --- a/KitX Clients/KitX Core/KitX.Core/Tasks/TasksManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/Tasks/TasksManager.cs @@ -4,6 +4,7 @@ using KitX.Core.Contract.Tasks; using Serilog; using CTask = System.Threading.Tasks.Task; +using KitX.Core.DI; namespace KitX.Core.Tasks; @@ -12,17 +13,32 @@ namespace KitX.Core.Tasks; /// public class TasksManager : ITasksService { - private static TasksManager? _instance; + /// + /// Gets the singleton instance (resolves from ServiceHost when available). + /// Internal code should use constructor injection instead. + /// + public static TasksManager Instance + { + get + { + if (ServiceHost.IsInitialized) + return (TasksManager)ServiceHost.GetRequiredService(); + Log.Error("[TasksManager] Instance: ServiceHost not initialized! Returning orphan instance — " + + "this indicates a DI initialization order bug. Use ServiceHost/constructor injection instead."); + return new TasksManager(); + } + } /// - /// Gets the singleton instance + /// Kept for backward compatibility — ServiceHost is now the single source of truth. /// - internal static TasksManager Instance => _instance ??= new(); + [Obsolete("ServiceHost is now the single source of truth. This method is a no-op.")] + internal static void SetServiceProvider(IServiceProvider? sp) { /* no-op */ } /// - /// Private constructor + /// Creates a new tasks manager /// - private TasksManager() { } + public TasksManager() { } /// /// Runs a synchronous task diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScriptServiceImpl.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScriptServiceImpl.cs index 61a0fb01..84701bb7 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScriptServiceImpl.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScriptServiceImpl.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -7,6 +7,7 @@ using KitX.Core.Workflow.BlockScripting; using KitX.Shared.CSharp.Plugin; using Serilog; +using Microsoft.Extensions.DependencyInjection; namespace KitX.Core.Workflow; @@ -17,14 +18,40 @@ namespace KitX.Core.Workflow; internal class BlockScriptServiceImpl : IBlockScriptService { private readonly WorkflowRuntimeState _state; + private RealPluginManager? _realPluginManager; /// /// Initializes a new instance of BlockScriptServiceImpl. /// /// Shared runtime state. - internal BlockScriptServiceImpl(WorkflowRuntimeState state) + /// RealPluginManager instance from DI container (optional). + internal BlockScriptServiceImpl(WorkflowRuntimeState state, RealPluginManager? realPluginManager = null) { _state = state; + _realPluginManager = realPluginManager; + TrySetPluginManager(); + } + + /// + /// Sets the RealPluginManager after initialization. + /// This is needed when RealPluginManager is resolved after BlockScriptServiceImpl is created. + /// + internal void SetRealPluginManager(RealPluginManager? realPluginManager) + { + _realPluginManager = realPluginManager; + TrySetPluginManager(); + } + + /// + /// Try to set the plugin manager on the BlockScriptExecutor if conditions are met. + /// + private void TrySetPluginManager() + { + if (_state.BlockScriptExecutor != null && _state.IsParserInitialized && _realPluginManager != null) + { + Log.Information("[BlockScriptServiceImpl] Setting RealPluginManager. HashCode: {HashCode}", _realPluginManager.GetHashCode()); + _state.BlockScriptExecutor.SetPluginManager(_realPluginManager); + } } /// @@ -44,12 +71,10 @@ private BlockScriptExecutor BlockScriptExecutor { if (_state.BlockScriptExecutor == null) { + Log.Information("[BlockScriptServiceImpl] Creating new BlockScriptExecutor, IsParserInitialized = {_IsParserInitialized}", + _state.IsParserInitialized); _state.BlockScriptExecutor = new BlockScriptExecutor(); - if (_state.IsParserInitialized) - { - var realPluginManager = new RealPluginManager(PluginsServer.Instance); - _state.BlockScriptExecutor.SetPluginManager(realPluginManager); - } + TrySetPluginManager(); } return _state.BlockScriptExecutor; } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockCompiler.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockCompiler.cs deleted file mode 100644 index 5dbb2537..00000000 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockCompiler.cs +++ /dev/null @@ -1,235 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Text; -using KitX.Core.Contract.Workflow; -using Microsoft.CodeAnalysis.CSharp; -using Serilog; - -namespace KitX.Core.Workflow.BlockScripting; - -/// -/// Compiles a into a single CSharpScript code string, -/// enabling block-level precompilation instead of per-statement evaluation. -/// -/// Key design principles based on BlockScript grammar: -/// -/// Flow control statements (Branch/Loop/ToLoopCond/Break/Return) always terminate -/// a block — any code after them is dead code and will be truncated with a warning. -/// NextBlock = "BlockName" is executable code that sets -/// at runtime, not just declarative -/// metadata. It is preserved in compiled output. -/// When a block has no explicit NextBlock assignment and no flow control -/// terminator, the compiler auto-completes NextBlock = "nextBlockName"; from -/// . -/// -/// -internal class BlockCompiler -{ - /// - /// Compiles a into a single CSharpScript code string. - /// - /// The block to compile. - /// - /// Variables already declared in the initialization script (e.g. ConstBlock/PubVarBlock variables). - /// These are omitted from re-declaration — the block only emits assignments for them. - /// - /// - /// The compiled CSharpScript code string, or null if the block is empty - /// and has no NextBlock to auto-complete. - /// - public string? CompileBlock(BlockDefinition block, HashSet predeclaredVariables) - { - var sb = new StringBuilder(); - - foreach (var statement in block.Statements) - { - switch (statement) - { - case ExpressionStatement exprStmt: - AppendExpression(sb, exprStmt, predeclaredVariables); - break; - - case VariableDeclarationStatement varStmt: - AppendVariableDeclaration(sb, varStmt, predeclaredVariables); - break; - - case FlowControlStatement flowStmt: - // Flow control terminates the block — append it and stop - AppendFlowControl(sb, flowStmt); - Log.Debug("[BlockCompiler] Block '{BlockName}': flow control ({ControlType}) " + - "terminates block at statement index, truncating any dead code after it", - block.Name, flowStmt.ControlType); - return Finalize(sb, block); - - default: - Log.Warning("[BlockCompiler] Block '{BlockName}': unknown statement type {Type} at line {Line}, skipping", - block.Name, statement.GetType().Name, statement.LineNumber); - break; - } - } - - // No flow control statement found — check if we need auto-completed NextBlock - return Finalize(sb, block); - } - - /// - /// Appends an expression statement to the code builder. - /// - private static void AppendExpression(StringBuilder sb, ExpressionStatement exprStmt, - HashSet predeclaredVariables) - { - var expr = exprStmt.Expression; - if (string.IsNullOrWhiteSpace(expr)) - return; - - // Handle NextBlock = "BlockName" — it's executable, keep it - // (the parser already extracts NextBlockName from plain string assignments, - // but the SourceCode preserves the original assignment expression for execution) - sb.AppendLine(expr + ";"); - } - - /// - /// Appends a variable declaration to the code builder. - /// For variables already declared in the init script, only the assignment is emitted. - /// - private static void AppendVariableDeclaration(StringBuilder sb, VariableDeclarationStatement varStmt, - HashSet predeclaredVariables) - { - var decl = varStmt.Declaration; - - if (predeclaredVariables.Contains(decl.Name)) - { - // Variable already declared in init script — just assign - if (!string.IsNullOrEmpty(decl.InitialValueExpression)) - { - sb.AppendLine($"{decl.Name} = {decl.InitialValueExpression};"); - } - else if (decl.DefaultValue != null) - { - var literal = FormatLiteral(decl.Type, decl.DefaultValue); - sb.AppendLine($"{decl.Name} = {literal};"); - } - // else: uninitialized predeclared variable — nothing to emit (already declared as dynamic) - } - else - { - // Not predeclared — need a declaration - if (!string.IsNullOrEmpty(decl.InitialValueExpression)) - { - sb.AppendLine($"var {decl.Name} = {decl.InitialValueExpression};"); - } - else if (decl.DefaultValue != null) - { - var literal = FormatLiteral(decl.Type, decl.DefaultValue); - sb.AppendLine($"var {decl.Name} = {literal};"); - } - else - { - // Uninitialized — declare with explicit type - sb.AppendLine($"{decl.Type} {decl.Name};"); - } - } - } - - /// - /// Appends a flow control statement to the code builder. - /// The SourceCode already contains the executable expression like - /// NextBlock = Loop(cond, "trueBlock", "falseBlock"); - /// - private static void AppendFlowControl(StringBuilder sb, FlowControlStatement flowStmt) - { - var source = flowStmt.SourceCode; - if (string.IsNullOrWhiteSpace(source)) - { - // Regenerate from fields if SourceCode is empty - flowStmt.RegenerateSourceCode(); - source = flowStmt.SourceCode; - } - - // Ensure it ends with semicolon - if (!source.TrimEnd().EndsWith(";")) - source = source.TrimEnd() + ";"; - - sb.AppendLine(source); - } - - /// - /// Finalizes the compiled block code: applies plugin call preprocessing - /// and auto-completes NextBlock if needed. - /// - private string? Finalize(StringBuilder sb, BlockDefinition block) - { - var code = sb.ToString(); - if (string.IsNullOrWhiteSpace(code) && string.IsNullOrEmpty(block.NextBlockName)) - { - // Empty block with no NextBlock — nothing to execute - return null; - } - - // Auto-complete NextBlock if the block has no explicit NextBlock assignment - // and no flow control terminator - if (!string.IsNullOrEmpty(block.NextBlockName) && !HasNextBlockAssignment(code)) - { - sb.AppendLine($"NextBlock = \"{block.NextBlockName}\";"); - code = sb.ToString(); - } - - // Apply plugin call preprocessing once on the entire block - code = PreProcessPluginCalls(code); - - return code; - } - - /// - /// Checks if the compiled code already contains a NextBlock assignment. - /// This includes both explicit assignments (NextBlock = "...";) and - /// flow control calls (NextBlock = Loop/Branch/Flip/ToLoopCond(...)). - /// - private static bool HasNextBlockAssignment(string code) - { - // Match: NextBlock = ...; (covers both string assignments and flow control) - // Using simple string search since the code is already well-formed C# - return code.Contains("NextBlock = "); - } - - /// - /// Formats a literal value for C# source code emission. - /// - private static string FormatLiteral(string type, object value) - { - return type switch - { - "string" => $"\"{value}\"", - "char" => $"'{value}'", - _ => value?.ToString() ?? "null" - }; - } - - /// - /// Preprocesses an entire block of code to rewrite dotted plugin calls - /// into PluginCall() invocations. Uses the same Roslyn SyntaxRewriter - /// as the per-statement version but operates on the whole block at once. - /// - private static string PreProcessPluginCalls(string code) - { - try - { - var syntaxTree = CSharpSyntaxTree.ParseText(code); - var root = syntaxTree.GetCompilationUnitRoot(); - var rewriter = new BlockScriptExecutor.PluginCallRewriter(); - var rewritten = rewriter.Visit(root); - - var result = rewritten.ToString(); - if (result != code) - { - Log.Debug("[BlockCompiler] PreProcessPluginCalls: rewrote plugin calls in block code"); - } - return result; - } - catch (System.Exception ex) - { - Log.Warning(ex, "[BlockCompiler] PreProcessPluginCalls: exception, returning original code"); - return code; - } - } -} \ No newline at end of file diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutor.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutor.cs index 84b804e9..93afe82c 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutor.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutor.cs @@ -1,53 +1,27 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; -using System.Reflection; using System.Threading; using System.Threading.Tasks; using KitX.Core.Contract.Workflow; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; -using Microsoft.CodeAnalysis.CSharp.Scripting; -using Microsoft.CodeAnalysis.Scripting; using KitX.Core.Workflow; using Serilog; namespace KitX.Core.Workflow.BlockScripting; /// -/// Block script executor - executes parsed block scripts using a call stack model +/// Block script executor using full-script assembly compilation. /// public class BlockScriptExecutor : IBlockScriptExecutor { - /// - /// Shared ScriptOptions with Microsoft.CSharp reference for dynamic support - /// and core imports for CSharpScript evaluation. - /// - private static readonly ScriptOptions ScriptOptions = ScriptOptions.Default - .WithReferences( - typeof(BlockScriptExecutionGlobals).Assembly, - typeof(Microsoft.CSharp.RuntimeBinder.Binder).Assembly) - .WithImports("System", "KitX.Core.Workflow.BlockScripting", "KitX.Core.Workflow"); - private readonly BlockScopeManager _scopeManager; private readonly Stopwatch _stopwatch = new(); - private ScriptState? _scriptState; - private BlockScript? _currentScript; private List _output = new(); private BlockScriptExecutionGlobals? _globals; private IPluginManager? _pluginManager; - - // Workflow ID for disk persistence of compiled assemblies private string? _workflowId; - - // Block-level precompilation - private readonly BlockCompiler _blockCompiler = new(); - private Dictionary? _blockCodeCache; - private HashSet? _predeclaredVariables; - - // Full-script assembly compilation private readonly ScriptAssemblyCompiler _assemblyCompiler = new(); /// @@ -96,9 +70,6 @@ public void SetWorkflowId(string? workflowId) /// Compiles a BlockScript and persists it to disk (without executing). /// Used for pre-compilation at workflow save time. /// - /// The block script to compile. - /// Workflow ID for disk persistence. - /// True if compilation and persistence succeeded. public bool CompileForPersistence(BlockScript script, string workflowId) { try @@ -117,15 +88,13 @@ public bool CompileForPersistence(BlockScript script, string workflowId) /// Preloads all persisted compiled scripts for a workflow from disk /// into the in-memory cache. /// - /// Workflow ID to preload scripts for. - /// Number of scripts successfully loaded. public int PreloadFromDisk(string workflowId) { return _assemblyCompiler.PreloadFromDisk(workflowId); } /// - /// Executes a block script + /// Executes a block script using full-script assembly compilation. /// public async Task ExecuteAsync( BlockScript script, @@ -134,171 +103,51 @@ public async Task ExecuteAsync( { _stopwatch.Restart(); _output = new List(); - var executedBlockCount = 0; try { - // ── Fast path: Full-script assembly compilation ── - // Try to compile the entire script into a .NET assembly and run it directly. - // This eliminates all per-block CSharpScript overhead (ContinueWithAsync). - // On failure, falls back to the existing CSharpScript execution path. - try - { - var compiled = _assemblyCompiler.CompileScript(script, _workflowId); - if (compiled != null) - { - Log.Debug("[BlockScriptExecutor] Using assembly-compiled execution path"); - _globals = new BlockScriptExecutionGlobals(_scopeManager, _output, _pluginManager); - _globals.ResetRunState(); - _scopeManager.InitializeGlobalScope(script); - - // Import parameters - if (parameters != null) - { - foreach (var p in parameters) - _globals.Set(p.Key, p.Value); - } - - compiled.Run(_globals, cancellationToken); - - _stopwatch.Stop(); - return new BlockScriptExecutionResult - { - IsSuccess = true, - ExecutedBlockCount = _globals.ExecutedBlockCount, - ExecutionTimeMs = _stopwatch.ElapsedMilliseconds, - Output = _output - }; - } - } - catch (OperationCanceledException) { throw; } - catch (Exception ex) - { - Log.Warning(ex, "[BlockScriptExecutor] Assembly compilation failed, falling back to CSharpScript"); - } - - // ── Fallback path: CSharpScript execution (per-block) ── - // Store script reference for use in EvaluateExpressionAsync - _currentScript = script; - - // Clear previous execution state - _scopeManager.ClearLocalScopes(); - _output.Clear(); // Clear the executor's output list - _scriptState = null; - - // Clear block compilation cache for new execution - _blockCodeCache = new Dictionary(); - - // Collect predeclared variable names (declared in initialization script) - // These variables are already declared as dynamic/var in the init script, - // so blocks should only emit assignments, not re-declarations - _predeclaredVariables = CollectPredeclaredVariables(script); - - // Reset run-level state (e.g. Flip counter) for fresh execution from Entry - _globals?.ResetRunState(); - - // Initialize global scope with ConstBlock and PubVarBlock - _scopeManager.InitializeGlobalScope(script); - - // Import parameters into global scope - if (parameters != null) - { - foreach (var param in parameters) - { - _scopeManager.SetVariable(param.Key, param.Value, global: true); - } - } - - // Initialize CSharpScript session with all variable declarations - await InitializeScriptSessionAsync(cancellationToken); - - // Execute ConstBlock and PubVarBlock (global variables) FIRST for initialization only - // They are executed ONCE to establish variables in scope, then we proceed to MainBlock - // NOTE: We do NOT follow their NextBlockName chains - those are only for sequential linking - Log.Debug("[BlockScriptExecutor] === Starting Block Execution ==="); - Log.Debug("[BlockScriptExecutor] script.ConstBlock is {IsNull}, script.PubVarBlock is {IsNull2}, script.MainBlock is {IsNull3}", - script.ConstBlock == null ? "null" : "NOT null", - script.PubVarBlock == null ? "null" : "NOT null", - script.MainBlock == null ? "null" : "NOT null"); - - // Execute ConstBlock if exists (just for initialization) - if (script.ConstBlock != null) - { - Log.Debug("[BlockScriptExecutor] >>> About to execute ConstBlock (initialization only)"); - executedBlockCount++; - await ExecuteBlockAsync(script.ConstBlock, cancellationToken); - Log.Debug("[BlockScriptExecutor] <<< ConstBlock initialization complete"); - } - - // Execute PubVarBlock if exists (just for initialization) - if (script.PubVarBlock != null) - { - Log.Debug("[BlockScriptExecutor] >>> About to execute PubVarBlock (initialization only)"); - executedBlockCount++; - await ExecuteBlockAsync(script.PubVarBlock, cancellationToken); - Log.Debug("[BlockScriptExecutor] <<< PubVarBlock initialization complete"); - } - - // MainBlock is the entry point for execution - if (script.MainBlock == null) + // Full-script assembly compilation + var compiled = _assemblyCompiler.CompileScript(script, _workflowId); + if (compiled == null) { + _stopwatch.Stop(); return new BlockScriptExecutionResult { IsSuccess = false, - ErrorMessage = "No MainBlock found in script", - ExecutedBlockCount = executedBlockCount, + ErrorMessage = "Script compilation failed", ExecutionTimeMs = _stopwatch.ElapsedMilliseconds, Output = _output }; } - Log.Debug("[BlockScriptExecutor] >>> About to execute MainBlock as entry point"); - executedBlockCount++; - var mainResult = await ExecuteBlockAsync(script.MainBlock, cancellationToken); - Log.Debug("[BlockScriptExecutor] <<< MainBlock executed, mainResult.NextBlockName = {NextBlock}, ShouldContinue = {ShouldContinue}", - mainResult.NextBlockName ?? "(null)", mainResult.ShouldContinue); + Log.Debug("[BlockScriptExecutor] Using assembly-compiled execution path"); + _globals = new BlockScriptExecutionGlobals(_scopeManager, _output, _pluginManager); + _globals.ResetRunState(); + _scopeManager.InitializeGlobalScope(script); - // Follow the execution chain using while loop - while (mainResult.ShouldContinue && !string.IsNullOrEmpty(mainResult.NextBlockName)) + // Import parameters + if (parameters != null) { - var nextBlockName = mainResult.NextBlockName; - - Log.Debug("[BlockScriptExecutor] Chain continuation: nextBlockName = {NextBlock}", - nextBlockName ?? "(null)"); - - if (string.IsNullOrEmpty(nextBlockName)) - { - Log.Debug("[BlockScriptExecutor] No more blocks in chain, ending execution"); - break; - } - - var nextBlock = script.GetBlockByName(nextBlockName); - Log.Debug("[BlockScriptExecutor] Fetched nextBlock: {BlockName} (type={BlockType})", - nextBlock?.Name ?? "null", nextBlock?.Type); - if (nextBlock == null) - { - Log.Warning("[BlockScriptExecutor] Block '{BlockName}' not found, ending chain", nextBlockName); - break; - } - - var (chainResult, newBlockCount, _) = await ExecuteBlockChainAsync(nextBlock, cancellationToken, executedBlockCount); - mainResult = chainResult; - executedBlockCount = newBlockCount; - - Log.Debug("[BlockScriptExecutor] Chain iteration complete: mainResult.NextBlockName = {NextBlock}, ShouldContinue = {ShouldContinue}", - mainResult.NextBlockName ?? "(null)", mainResult.ShouldContinue); + foreach (var p in parameters) + _globals.Set(p.Key, p.Value); } + compiled.Run(_globals, cancellationToken); + _stopwatch.Stop(); return new BlockScriptExecutionResult { - IsSuccess = mainResult.ShouldContinue || mainResult.IsReturn, - ReturnValue = mainResult.ReturnValue, - ExecutedBlockCount = executedBlockCount, + IsSuccess = true, + ExecutedBlockCount = _globals.ExecutedBlockCount, ExecutionTimeMs = _stopwatch.ElapsedMilliseconds, Output = _output }; } + catch (OperationCanceledException) + { + _stopwatch.Stop(); + throw; + } catch (Exception ex) { _stopwatch.Stop(); @@ -307,113 +156,12 @@ public async Task ExecuteAsync( { IsSuccess = false, ErrorMessage = $"Execution error: {ex.Message}", - ExecutedBlockCount = executedBlockCount, ExecutionTimeMs = _stopwatch.ElapsedMilliseconds, Output = _output }; } } - /// - /// Executes a chain of blocks following NextBlockName - /// Note: This only follows NextBlockName when a block ends NATURALLY (no flow control). - /// When flow control returns a NextBlockName (Loop/Branch), caller should handle the跳转. - /// - /// Block to start execution from - /// Cancellation token - /// Initial block count for tracking - /// Tuple of (Result, ExecutedBlockCount, ChainEndedNaturally) where ChainEndedNaturally - /// is true if chain ended naturally (no flow control jump), false if returned early due to - /// flow control setting NextBlockName - private async Task<(BlockExecutionResult Result, int ExecutedBlockCount, bool ChainEndedNaturally)> ExecuteBlockChainAsync( - BlockDefinition startBlock, - CancellationToken cancellationToken, - int initialBlockCount) - { - var chainEndedNaturally = false; - var currentBlock = startBlock; - var executedBlockCount = initialBlockCount; - - while (currentBlock != null) - { - cancellationToken.ThrowIfCancellationRequested(); - - executedBlockCount++; - Log.Debug("[BlockScriptExecutor] Executing block '{BlockName}' (type={BlockType})", - currentBlock.Name, currentBlock.Type); - - // Execute block and get result - var result = await ExecuteBlockAsync(currentBlock, cancellationToken); - - // Check if execution should continue - if (!result.ShouldContinue) - { - // Return/break - propagate result up to caller - return (result, executedBlockCount, chainEndedNaturally); - } - - // If NextBlockName is set from flow control (Loop/Branch), return to caller - // DO NOT auto-jump - caller will handle the跳转 - if (!string.IsNullOrEmpty(result.NextBlockName)) - { - Log.Debug("[BlockScriptExecutor] Block '{BlockName}' has flow control NextBlockName = '{NextBlock}', returning to caller", - currentBlock.Name, result.NextBlockName); - // chainEndedNaturally is already false - return (result, executedBlockCount, false); - } - - // Block ended naturally - follow NextBlockName if exists - if (string.IsNullOrEmpty(currentBlock.NextBlockName)) - { - // End of execution chain - Log.Debug("[BlockScriptExecutor] Block '{BlockName}' has no NextBlockName, ending chain", - currentBlock.Name); - chainEndedNaturally = true; - break; - } - - currentBlock = _currentScript?.GetBlockByName(currentBlock.NextBlockName); - if (currentBlock == null) - { - Log.Warning("[BlockScriptExecutor] Block '{BlockName}' references non-existent NextBlock '{NextBlock}'", - currentBlock?.Name, currentBlock?.NextBlockName); - chainEndedNaturally = true; - break; - } - } - - // Chain ended naturally - chainEndedNaturally = true; - return (BlockExecutionResult.ContinueTo(null), executedBlockCount, true); - } - - /// - /// Executes a specific block by name - /// - public async Task ExecuteBlockAsync( - BlockScript script, - string blockName, - Dictionary? parameters = null, - CancellationToken cancellationToken = default) - { - if (!script.NamedBlocks.TryGetValue(blockName, out var block)) - { - return new BlockScriptExecutionResult - { - IsSuccess = false, - ErrorMessage = $"Block '{blockName}' not found" - }; - } - - await ExecuteBlockAsync(block, cancellationToken); - - return new BlockScriptExecutionResult - { - IsSuccess = true, - ExecutedBlockCount = 1 - }; - } - /// /// Validates a block script /// @@ -463,7 +211,6 @@ public BlockScriptValidationResult Validate(BlockScript script) } else if (flow.ControlType == FlowControlType.Loop) { - // Loop(cond, trueBlock, falseBlock) syntax if (!string.IsNullOrEmpty(flow.TrueBlockName) && !allBlockNames.Contains(flow.TrueBlockName)) { @@ -477,7 +224,6 @@ public BlockScriptValidationResult Validate(BlockScript script) } else if (flow.ControlType == FlowControlType.ToLoopCond) { - // ToLoopCond should reference a block that contains a Loop if (!string.IsNullOrEmpty(flow.ToLoopCondReturnTo) && !allBlockNames.Contains(flow.ToLoopCondReturnTo)) { @@ -493,595 +239,4 @@ public BlockScriptValidationResult Validate(BlockScript script) return result; } - - /// - /// Executes a single block and returns execution result for state machine. - /// Uses block-level precompilation for MainBlock/NamedBlock (one ContinueWithAsync per block), - /// and statement-by-statement execution for ConstBlock/PubVarBlock (special DefaultValue handling). - /// - private async Task ExecuteBlockAsync( - BlockDefinition block, - CancellationToken cancellationToken) - { - // ConstBlock and PubVarBlock: keep statement-by-statement execution - // (they only run once and have special DefaultValue handling) - if (block.Type == BlockType.ConstBlock || block.Type == BlockType.PubVarBlock) - return await ExecuteBlockAsyncStatementByStatement(block, cancellationToken); - - // MainBlock/NamedBlock/LoopBlock: use block-level precompilation - return await ExecuteBlockAsyncPrecompiled(block, cancellationToken); - } - - /// - /// Executes a block using precompiled code (single ContinueWithAsync call for the entire block). - /// This is the fast path that eliminates per-statement compilation overhead. - /// - private async Task ExecuteBlockAsyncPrecompiled( - BlockDefinition block, - CancellationToken cancellationToken) - { - _globals?.ResetNextBlock(); - - var blockCode = GetOrCompileBlockCode(block); - if (string.IsNullOrWhiteSpace(blockCode)) - { - // Empty block with no NextBlock — just continue - return BlockExecutionResult.ContinueTo(block.NextBlockName); - } - - try - { - await EvaluateBlockCodeAsync(blockCode, cancellationToken); - } - catch (Exception ex) - { - Log.Warning(ex, "[BlockScriptExecutor] Precompiled block '{BlockName}' failed, falling back to statement-by-statement", - block.Name); - // Fallback to statement-by-statement execution on precompilation failure - _globals?.ResetNextBlock(); - return await ExecuteBlockAsyncStatementByStatement(block, cancellationToken); - } - - // NextBlock was set by block code (NextBlock=xxx, Branch, Loop, ToLoopCond, Flip) - if (!string.IsNullOrEmpty(_globals?.NextBlock)) - return BlockExecutionResult.ContinueTo(_globals.NextBlock); - - // Fallback: if no NextBlock was set, use BlockDefinition.NextBlockName - return BlockExecutionResult.ContinueTo(block.NextBlockName); - } - - /// - /// Evaluates precompiled block code (multi-statement) via ContinueWithAsync. - /// Unlike EvaluateExpressionAsync, this does NOT wrap code or apply PreProcessPluginCalls - /// (those are already handled by BlockCompiler during compilation). - /// - private async Task EvaluateBlockCodeAsync(string blockCode, CancellationToken cancellationToken) - { - if (_scriptState == null) - { - // Should not happen — session is initialized before block execution - _globals ??= new BlockScriptExecutionGlobals(_scopeManager, _output, _pluginManager); - _scriptState = await CSharpScript.RunAsync( - blockCode, ScriptOptions, globals: _globals, cancellationToken: cancellationToken); - return _scriptState.ReturnValue; - } - - _scriptState = await _scriptState.ContinueWithAsync( - blockCode, ScriptOptions, cancellationToken: cancellationToken); - return _scriptState.ReturnValue; - } - - /// - /// Gets compiled block code from cache, or compiles it on first access. - /// - private string? GetOrCompileBlockCode(BlockDefinition block) - { - if (_blockCodeCache == null) - _blockCodeCache = new Dictionary(); - - if (!_blockCodeCache.TryGetValue(block.Name, out var code)) - { - code = _blockCompiler.CompileBlock(block, _predeclaredVariables ?? new HashSet()) ?? string.Empty; - _blockCodeCache[block.Name] = code; - Log.Debug("[BlockScriptExecutor] Compiled block '{BlockName}' ({StatementCount} statements) → {CodeLength} chars", - block.Name, block.Statements.Count, code.Length); - } - return code; - } - - /// - /// Collects variable names declared in the initialization script (ConstBlock + PubVarBlock). - /// These variables are predeclared, so blocks should only emit assignments, not re-declarations. - /// - private static HashSet CollectPredeclaredVariables(BlockScript script) - { - var variables = new HashSet(); - if (script.ConstBlock != null) - { - foreach (var v in script.ConstBlock.Variables) - variables.Add(v.Name); - } - if (script.PubVarBlock != null) - { - foreach (var v in script.PubVarBlock.Variables) - variables.Add(v.Name); - } - return variables; - } - - /// - /// Executes a single block statement-by-statement (legacy path). - /// Used for ConstBlock/PubVarBlock (special DefaultValue handling) - /// and as fallback when precompilation fails. - /// - private async Task ExecuteBlockAsyncStatementByStatement( - BlockDefinition block, - CancellationToken cancellationToken) - { - // Reset NextBlock at the start of each block - _globals?.ResetNextBlock(); - - // Execute statements in order - for (int i = 0; i < block.Statements.Count; i++) - { - cancellationToken.ThrowIfCancellationRequested(); - - var statement = block.Statements[i]; - await ExecuteStatementAsync(block, statement, cancellationToken); - - // Check if NextBlock was set by the statement (Loop/Branch/assignment) - if (!string.IsNullOrEmpty(_globals?.NextBlock)) - { - // NextBlock was set - use it for跳转 - var nextBlock = _globals.NextBlock; - _globals.ResetNextBlock(); // Reset for next block - return BlockExecutionResult.ContinueTo(nextBlock); - } - // Otherwise, continue to next statement - } - - // Block ended naturally - continue to next block if exists (natural flow) - return BlockExecutionResult.ContinueTo(block.NextBlockName); - } - - /// - /// Executes a single statement and returns execution result for state machine - /// - private async Task ExecuteStatementAsync( - BlockDefinition currentBlock, - BlockStatement statement, - CancellationToken cancellationToken) - { - try - { - if (statement is ExpressionStatement exprStmt) - { - await ExecuteExpressionAsync(exprStmt.Expression, cancellationToken); - return BlockExecutionResult.ContinueTo(null); // Continue to next statement - } - else if (statement is VariableDeclarationStatement varStmt) - { - // Check if DefaultValue is pre-computed (for ConstBlock variables) - // If so, use it directly instead of evaluating through CSharpScript - if (varStmt.Declaration.DefaultValue != null) - { - _scopeManager.SetVariable(varStmt.Declaration.Name, varStmt.Declaration.DefaultValue); - Serilog.Log.Debug("[BlockScriptExecutor] Declared variable {Name} = {Value} (from DefaultValue)", - varStmt.Declaration.Name, varStmt.Declaration.DefaultValue); - } - else - { - // For PubVarBlock or variables without pre-computed values, - // evaluate the initial value expression - var initialValue = varStmt.Declaration.InitialValueExpression; - if (!string.IsNullOrEmpty(initialValue)) - { - try - { - // Evaluate just the initial value expression - var valueResult = await EvaluateExpressionAsync(initialValue, cancellationToken); - // Store the variable via SetVariable - _scopeManager.SetVariable(varStmt.Declaration.Name, valueResult); - Serilog.Log.Debug("[BlockScriptExecutor] Declared variable {Name} = {Value}", - varStmt.Declaration.Name, valueResult); - } - catch (Exception ex) - { - Log.Warning(ex, "[BlockScriptExecutor] Error evaluating initial value for {Name}", - varStmt.Declaration.Name); - // Still declare the variable with null/default - _scopeManager.SetVariable(varStmt.Declaration.Name, null); - } - } - else - { - // Declare without initial value - _scopeManager.SetVariable(varStmt.Declaration.Name, null); - } - } - return BlockExecutionResult.ContinueTo(null); // Continue to next statement - } - else if (statement is FlowControlStatement flowStmt) - { - // ExecuteFlowControlAsync returns BlockExecutionResult for state machine - return await ExecuteFlowControlAsync(currentBlock, flowStmt, cancellationToken); - } - return BlockExecutionResult.ContinueTo(null); // Continue to next statement - } - catch (Exception ex) - { - Log.Error(ex, "[BlockScriptExecutor] Error executing statement at line {Line}", statement.LineNumber); - throw; - } - } - - /// - /// Executes an expression statement - /// - private async Task ExecuteExpressionAsync( - string expression, - CancellationToken cancellationToken) - { - if (string.IsNullOrWhiteSpace(expression)) - return; - - // Execute the expression (assignment or function call) - await EvaluateExpressionAsync(expression, cancellationToken); - } - - /// - /// Evaluates an expression and returns the result - /// - private async Task EvaluateExpressionAsync( - string expression, - CancellationToken cancellationToken) - { - try - { - // Pre-process: rewrite dotted plugin calls (e.g. "Plugin.Func()") to PluginCall(...) - expression = PreProcessPluginCalls(expression); - - // Build a complete statement for execution - var code = WrapExpressionAsStatement(expression); - - // Determine if we need to prepend helpers: - // - If _scriptState is null (first evaluation): prepend helpers, they need to be defined - // - If _scriptState != null (subsequent evaluations via ContinueWithAsync): - // DON'T prepend helpers, they were already defined in the initialization script - // and ContinueWithAsync shares the same script state - var fullCode = _scriptState == null - ? BuildCodeWithHelpers(code) - : code; - - // Log the full code being evaluated (truncated if too long) - var codePreview = fullCode.Length > 500 ? fullCode.Substring(0, 500) + "..." : fullCode; - Log.Debug("[BlockScriptExecutor] Evaluating expression. Code:\n{Code}", codePreview); - - object? result; - if (_scriptState != null) - { - // Use ContinueWithAsync to evaluate in the context of the compiled script - // Variables and helpers declared in previous evaluations are preserved - _scriptState = await _scriptState.ContinueWithAsync( - fullCode, - ScriptOptions, - cancellationToken: cancellationToken); - result = _scriptState.ReturnValue; - } - else - { - // First evaluation: create initial script state using RunAsync to get ScriptState - _globals = new BlockScriptExecutionGlobals(_scopeManager, _output, _pluginManager); - var globals = _globals; - _scriptState = await CSharpScript.RunAsync( - fullCode, - ScriptOptions, - globals: globals, - cancellationToken: cancellationToken); - result = _scriptState.ReturnValue; - } - - return result; - } - catch (Exception ex) - { - Log.Error(ex, "[BlockScriptExecutor] Error evaluating expression: {Expression}", expression); - throw; - } - } - - /// - /// Wraps an expression as a statement for evaluation - /// - private string WrapExpressionAsStatement(string expression) - { - expression = expression.Trim(); - if (expression.EndsWith(';')) - return expression; - return expression + ";"; - } - - /// - /// Preprocesses an expression to rewrite dotted plugin calls into PluginCall() invocations. - /// Uses Roslyn SyntaxRewriter for safe, AST-level transformation that correctly handles - /// nested parentheses, complex expressions, and all edge cases. - /// Transforms: TestPlugin.WPF.Core.HelloKitX(arg1, arg2) - /// Into: PluginCall("TestPlugin.WPF.Core", "HelloKitX", arg1, arg2) - /// Transforms: TestPlugin.WPF.Core.HelloKitX() - /// Into: PluginCall("TestPlugin.WPF.Core", "HelloKitX") - /// - private static string PreProcessPluginCalls(string expression) - { - try - { - var wrappedCode = "_ = " + expression + ";"; - var syntaxTree = CSharpSyntaxTree.ParseText(wrappedCode); - var root = syntaxTree.GetCompilationUnitRoot(); - - var rewriter = new PluginCallRewriter(); - var rewritten = rewriter.Visit(root); - - // Extract the expression back from "_ = ...;" - if (rewritten is CompilationUnitSyntax cu - && cu.Members.FirstOrDefault() is GlobalStatementSyntax gs - && gs.Statement is ExpressionStatementSyntax ess - && ess.Expression is AssignmentExpressionSyntax aes) - { - var result = aes.Right.ToString(); - if (result != expression) - { - Log.Debug("[BlockScriptExecutor] PreProcessPluginCalls: '{Original}' → '{Result}'", - expression, result); - } - return result; - } - - Log.Warning("[BlockScriptExecutor] PreProcessPluginCalls: extraction failed for '{Expression}', " + - "rewritten type={Type}", expression, rewritten?.GetType().Name); - return expression; - } - catch (Exception ex) - { - Log.Warning(ex, "[BlockScriptExecutor] PreProcessPluginCalls: exception for '{Expression}'", - expression); - return expression; - } - } - - /// - /// Roslyn SyntaxRewriter that transforms dotted member-access invocations - /// (e.g. TestPlugin.WPF.Core.HelloKitX()) into PluginCall("...", "...", args) calls. - /// Operates at the AST level, preserving all expression structure and handling nesting. - /// - internal class PluginCallRewriter : CSharpSyntaxRewriter - { - public override SyntaxNode VisitInvocationExpression(InvocationExpressionSyntax node) - { - // First, recursively rewrite any nested invocations in arguments - node = (InvocationExpressionSyntax)base.VisitInvocationExpression(node)!; - - // Only rewrite member-access invocations (e.g. A.B.C.Method()) - if (node.Expression is not MemberAccessExpressionSyntax member) - return node; - - // Only rewrite when the expression has dots (plugin namespace path) - var fullExpression = member.Expression.ToString(); - if (!fullExpression.Contains('.')) - return node; - - var pluginName = fullExpression; - var funcName = member.Name.Identifier.Text; - - Log.Debug("[PluginCallRewriter] Rewriting: {Plugin}.{Method}() → PluginCall()", - pluginName, funcName); - - // Build: PluginCall("pluginName", "funcName" [, existingArgs]) - // 所有分发策略(类型化调用、f-a-f vs 同步等待)由 Manager 内部自动处理 - var args = new List - { - SyntaxFactory.Argument( - SyntaxFactory.LiteralExpression( - SyntaxKind.StringLiteralExpression, - SyntaxFactory.Literal(pluginName))), - SyntaxFactory.Argument( - SyntaxFactory.LiteralExpression( - SyntaxKind.StringLiteralExpression, - SyntaxFactory.Literal(funcName))) - }; - - // Append original arguments - args.AddRange(node.ArgumentList.Arguments); - - var newArgsList = SyntaxFactory.SeparatedList(args); - var newInvocation = SyntaxFactory.InvocationExpression( - SyntaxFactory.IdentifierName("PluginCall"), - SyntaxFactory.ArgumentList(newArgsList)); - - return newInvocation; - } - } - - /// - /// Builds helper function code from a list of helper functions - /// - private string BuildHelperFunctionsCode(List? helperFunctions) - { - return HelperFunctionCodeGenerator.GenerateCode(helperFunctions, useStaticModifier: false); - } - - /// - /// Builds complete code by prepending helper function definitions - /// - private string BuildCodeWithHelpers(string code) - { - var helperCode = BuildHelperFunctionsCode(_currentScript?.HelperFunctions); - if (string.IsNullOrEmpty(helperCode)) - return code; - - return helperCode + code; - } - - /// - /// Builds a complete initialization script that declares all variables - /// This should be run once to properly initialize the CSharpScript session - /// - private string BuildInitializationScript(BlockScript script) - { - var initCode = new System.Text.StringBuilder(); - - // Add helper functions first - initCode.Append(BuildHelperFunctionsCode(script.HelperFunctions)); - - // Add variable declarations from ConstBlock and PubVarBlock - // IMPORTANT: All variables must be declared in CSharpScript, even without initial values - // - // Key distinction: - // - With DefaultValue: use "var name = value;" (C# can infer the type from the literal value) - // - Without DefaultValue: use explicit type "type name;" (C# requires explicit type for uninitialized vars) - // Note: "var name;" is INVALID in C# because implicitly-typed variables must be initialized - if (script.ConstBlock != null) - { - foreach (var variable in script.ConstBlock.Variables) - { - if (variable.DefaultValue != null) - { - // Prefer InitialValueExpression (preserves original C# source with - // proper quoting/escaping) over DefaultValue (raw .NET object). - // For string/char types, DefaultValue lacks quotes or escape sequences. - var initExpr = !string.IsNullOrEmpty(variable.InitialValueExpression) - ? variable.InitialValueExpression - : variable.Type switch - { - "string" => $"\"{variable.DefaultValue}\"", - "char" => $"'{variable.DefaultValue}'", - _ => variable.DefaultValue.ToString()! - }; - initCode.AppendLine($"var {variable.Name} = {initExpr};"); - } - else - { - // Use explicit type since there's no initializer - initCode.AppendLine($"{variable.Type} {variable.Name};"); - } - } - } - - if (script.PubVarBlock != null) - { - foreach (var variable in script.PubVarBlock.Variables) - { - if (variable.DefaultValue != null) - { - // Prefer InitialValueExpression (preserves original C# source with - // proper quoting/escaping) over DefaultValue (raw .NET object). - var initExpr = !string.IsNullOrEmpty(variable.InitialValueExpression) - ? variable.InitialValueExpression - : variable.Type switch - { - "string" => $"\"{variable.DefaultValue}\"", - "char" => $"'{variable.DefaultValue}'", - _ => variable.DefaultValue.ToString()! - }; - initCode.AppendLine($"var {variable.Name} = {initExpr};"); - } - else - { - // Use explicit type since there's no initializer - initCode.AppendLine($"{variable.Type} {variable.Name};"); - } - } - } - - return initCode.ToString(); - } - - /// - /// Initializes the CSharpScript session with all variable declarations - /// - private async Task InitializeScriptSessionAsync(CancellationToken cancellationToken) - { - if (_currentScript == null) return; - - var initCode = BuildInitializationScript(_currentScript); - if (string.IsNullOrWhiteSpace(initCode)) - { - // No initialization needed, just do a simple first evaluation to establish session - _globals = new BlockScriptExecutionGlobals(_scopeManager, _output, _pluginManager); - _scriptState = await CSharpScript.RunAsync( - "0", // Simple expression to establish session - ScriptOptions, - globals: _globals, - cancellationToken: cancellationToken); - return; - } - - Log.Debug("[BlockScriptExecutor] Running initialization script:\n{InitCode}", initCode); - - try - { - _globals = new BlockScriptExecutionGlobals(_scopeManager, _output, _pluginManager); - _scriptState = await CSharpScript.RunAsync( - initCode, - ScriptOptions, - globals: _globals, - cancellationToken: cancellationToken); - } - catch (Exception ex) - { - Log.Warning(ex, "[BlockScriptExecutor] Initialization script failed, will try without it"); - // Fall back to simple session establishment - _globals = new BlockScriptExecutionGlobals(_scopeManager, _output, _pluginManager); - _scriptState = await CSharpScript.RunAsync( - "0", - ScriptOptions, - globals: _globals, - cancellationToken: cancellationToken); - } - } - - /// - /// Executes flow control statement and returns result for state machine - /// - private async Task ExecuteFlowControlAsync( - BlockDefinition currentBlock, - FlowControlStatement statement, - CancellationToken cancellationToken) - { - switch (statement.ControlType) - { - case FlowControlType.Return: - // Return statement: evaluate the return value expression - if (!string.IsNullOrEmpty(statement.ConditionExpression)) - { - var returnValue = await EvaluateExpressionAsync(statement.ConditionExpression, cancellationToken); - return BlockExecutionResult.Return(returnValue); - } - return BlockExecutionResult.Return(null); - - case FlowControlType.Break: - // Break exits the current loop - for now, just end the script - return BlockExecutionResult.Return(null); - - default: - // Branch, Loop, ToLoopCond, etc. - // Execute the full statement (e.g., "NextBlock = Loop(...)" or "NextBlock = Branch(...)") - // The built-in function will set _globals.NextBlock - await EvaluateExpressionAsync(statement.SourceCode, cancellationToken); - - // Check if NextBlock was set by the built-in function - var nextBlock = _globals?.NextBlock; - if (!string.IsNullOrEmpty(nextBlock)) - { - return BlockExecutionResult.ContinueTo(nextBlock); - } - - // Fallback: if NextBlock wasn't set, use the stored target based on control type - return statement.ControlType switch - { - FlowControlType.Loop => BlockExecutionResult.ContinueTo(statement.TrueBlockName), - FlowControlType.Branch => BlockExecutionResult.ContinueTo(statement.TrueBlockName), - _ => BlockExecutionResult.ContinueTo(null) - }; - } - } } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/BranchFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/BranchFunction.cs index 33770d02..7c7f404d 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/BranchFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/BranchFunction.cs @@ -2,6 +2,7 @@ using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; using KitX.Core.Workflow.Blueprint; +using KitX.Core.Workflow.Blueprint.CFG; using KitX.Core.Workflow.Blueprint.Pipeline; using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Pins; @@ -19,7 +20,7 @@ public class BranchFunction : IBuiltinFunctionDefinition public bool IsNonExtractable => false; public BlueprintNodeType? LegacyNodeType => BlueprintNodeType.Branch; public bool IsBlockTerminator => true; - public FormattedStatementKind StatementKind => FormattedStatementKind.Branch; + public CFGStatementKind StatementKind => CFGStatementKind.Branch; public double NodeWidth => 120; public double NodeHeight => 80; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/BreakFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/BreakFunction.cs index 7426599e..4089bd47 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/BreakFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/BreakFunction.cs @@ -2,6 +2,7 @@ using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; using KitX.Core.Workflow.Blueprint; +using KitX.Core.Workflow.Blueprint.CFG; using KitX.Core.Workflow.Blueprint.Pipeline; namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions; @@ -17,7 +18,7 @@ public class BreakFunction : IBuiltinFunctionDefinition public bool IsBlockTerminator => true; public bool IsNonExtractable => true; public BlueprintNodeType? LegacyNodeType => BlueprintNodeType.Break; - public FormattedStatementKind StatementKind => FormattedStatementKind.Break; + public CFGStatementKind StatementKind => CFGStatementKind.Break; public double NodeWidth => 100; public double NodeHeight => 40; @@ -37,7 +38,7 @@ public List FormatInvocation( return [new FormattedStatement { BlockName = blockName, - Kind = FormattedStatementKind.Break, + Kind = CFGStatementKind.Break, FunctionName = FunctionName, OriginalExpression = invoke.ToString(), SourceLine = 0, diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/FlipFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/FlipFunction.cs index 88bdf06a..cbf872aa 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/FlipFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/FlipFunction.cs @@ -2,6 +2,7 @@ using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; using KitX.Core.Workflow.Blueprint; +using KitX.Core.Workflow.Blueprint.CFG; using KitX.Core.Workflow.Blueprint.Pipeline; namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions @@ -20,7 +21,7 @@ public class FlipFunction : IBuiltinFunctionDefinition public bool IsFlowControl => true; public bool IsNonExtractable => true; public bool IsBlockTerminator => true; - public FormattedStatementKind StatementKind => FormattedStatementKind.Branch; + public CFGStatementKind StatementKind => CFGStatementKind.Branch; public double NodeWidth => 120; public double NodeHeight => 80; @@ -59,7 +60,7 @@ public List FormatInvocation( return [new FormattedStatement { BlockName = blockName, - Kind = FormattedStatementKind.Branch, // Reuse Branch kind for pipeline routing + Kind = CFGStatementKind.Branch, // Reuse Branch kind for pipeline routing FunctionName = FunctionName, TrueBlockName = trueBlock, FalseBlockName = falseBlock, diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/GetFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/GetFunction.cs index 959cf133..71fc53c9 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/GetFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/GetFunction.cs @@ -2,6 +2,7 @@ using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; using KitX.Core.Workflow.Blueprint; +using KitX.Core.Workflow.Blueprint.CFG; using KitX.Core.Workflow.Blueprint.Pipeline; namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions; @@ -16,7 +17,7 @@ public class GetFunction : IBuiltinFunctionDefinition public bool IsFlowControl => false; public bool IsNonExtractable => false; public BlueprintNodeType? LegacyNodeType => BlueprintNodeType.Get; - public FormattedStatementKind StatementKind => FormattedStatementKind.Assignment; + public CFGStatementKind StatementKind => CFGStatementKind.Assignment; public double NodeWidth => 120; public double NodeHeight => 60; @@ -62,7 +63,7 @@ public List FormatInvocation( return [new FormattedStatement { BlockName = blockName, - Kind = FormattedStatementKind.Assignment, + Kind = CFGStatementKind.Assignment, FunctionName = FunctionName, PubVarTarget = pubVarTarget, GetVarName = getVarName, diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/LoopFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/LoopFunction.cs index d5e940b9..b677dfd2 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/LoopFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/LoopFunction.cs @@ -2,6 +2,7 @@ using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; using KitX.Core.Workflow.Blueprint; +using KitX.Core.Workflow.Blueprint.CFG; using KitX.Core.Workflow.Blueprint.Pipeline; using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Pins; @@ -20,7 +21,7 @@ public class LoopFunction : IBuiltinFunctionDefinition public bool IsNonExtractable => false; public BlueprintNodeType? LegacyNodeType => BlueprintNodeType.Loop; public bool IsBlockTerminator => true; - public FormattedStatementKind StatementKind => FormattedStatementKind.Loop; + public CFGStatementKind StatementKind => CFGStatementKind.Loop; public double NodeWidth => 120; public double NodeHeight => 80; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PauseFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PauseFunction.cs index 663f7aa5..47718451 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PauseFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PauseFunction.cs @@ -2,6 +2,7 @@ using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; using KitX.Core.Workflow.Blueprint; +using KitX.Core.Workflow.Blueprint.CFG; using KitX.Core.Workflow.Blueprint.Pipeline; using System.Threading; @@ -17,7 +18,7 @@ public class PauseFunction : IBuiltinFunctionDefinition public bool IsFlowControl => false; public bool IsNonExtractable => true; public BlueprintNodeType? LegacyNodeType => BlueprintNodeType.Pause; - public FormattedStatementKind StatementKind => FormattedStatementKind.Pause; + public CFGStatementKind StatementKind => CFGStatementKind.Pause; public double NodeWidth => 100; public double NodeHeight => 50; @@ -40,7 +41,7 @@ public List FormatInvocation( return [new FormattedStatement { BlockName = blockName, - Kind = FormattedStatementKind.Pause, + Kind = CFGStatementKind.Pause, FunctionName = FunctionName, Arguments = args, OriginalExpression = invoke.ToString(), diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PluginCallFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PluginCallFunction.cs index f5da66f7..8d43f5e1 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PluginCallFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PluginCallFunction.cs @@ -4,6 +4,7 @@ using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; using KitX.Core.Workflow.Blueprint; +using KitX.Core.Workflow.Blueprint.CFG; using KitX.Core.Workflow.Blueprint.Pipeline; using KitX.Shared.CSharp.Device; using Serilog; @@ -20,7 +21,7 @@ public class PluginCallFunction : IBuiltinFunctionDefinition public bool IsFlowControl => false; public bool IsNonExtractable => true; public BlueprintNodeType? LegacyNodeType => null; - public FormattedStatementKind StatementKind => FormattedStatementKind.Expression; + public CFGStatementKind StatementKind => CFGStatementKind.Expression; public double NodeWidth => 140; public double NodeHeight => 80; @@ -47,7 +48,7 @@ public List FormatInvocation( return [new FormattedStatement { BlockName = blockName, - Kind = FormattedStatementKind.Expression, + Kind = CFGStatementKind.Expression, FunctionName = FunctionName, PubVarTarget = null, Arguments = args, diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PluginCallWithTargetFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PluginCallWithTargetFunction.cs index 990c89f3..332bf8a6 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PluginCallWithTargetFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PluginCallWithTargetFunction.cs @@ -5,6 +5,7 @@ using KitX.Core.Contract.Workflow; using KitX.Core.Device; using KitX.Core.Workflow.Blueprint; +using KitX.Core.Workflow.Blueprint.CFG; using KitX.Core.Workflow.Blueprint.Pipeline; using KitX.Shared.CSharp.Device; using Serilog; @@ -21,9 +22,9 @@ public class PluginCallWithTargetFunction : IBuiltinFunctionDefinition public string FunctionName => "PluginCallWithTarget"; public string DisplayName => "PluginCallWithTarget"; public bool IsFlowControl => false; - public bool IsNonExtractable => true; + public bool IsNonExtractable => false; public BlueprintNodeType? LegacyNodeType => BlueprintNodeType.Call; - public FormattedStatementKind StatementKind => FormattedStatementKind.PluginCallWithTarget; + public CFGStatementKind StatementKind => CFGStatementKind.PluginCallWithTarget; public double NodeWidth => 140; public double NodeHeight => 80; @@ -52,9 +53,9 @@ public List FormatInvocation( return [new FormattedStatement { BlockName = blockName, - Kind = FormattedStatementKind.PluginCallWithTarget, + Kind = CFGStatementKind.PluginCallWithTarget, FunctionName = FunctionName, - PubVarTarget = null, + PubVarTarget = assignedVar, // Set PubVarTarget so formatter creates PubVar assignment like other extractable functions Arguments = args, OriginalExpression = invoke.ToString(), SourceLine = 0, @@ -65,14 +66,86 @@ public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) { if (node is CallNode call) { - var args = stmt.Arguments; - if (args?.Count > 0) call.PluginName = StripQuotes(args[0]); - if (args?.Count > 1) call.FunctionName = StripQuotes(args[1]); - if (args?.Count > 2) call.TargetDevice = StripQuotes(args[2]); + // Use stmt.Arguments directly — it contains the properly expanded argument strings + // from the ScriptFormatter's ExpandArguments step. First 3 args are + // plugin name, method name, target device; remaining are extra args. + var allArgs = stmt.Arguments; + if (allArgs != null && allArgs.Count >= 3) + { + call.PluginName = StripQuotes(allArgs[0]); + call.FunctionName = StripQuotes(allArgs[1]); + call.TargetDevice = StripQuotes(allArgs[2]); + if (allArgs.Count > 3) + call.ExtraArguments = allArgs.Skip(3).ToList(); + } } return node; } + /// + /// Parses PluginCallWithTarget argument string to extract the first 3 string literal arguments. + /// Handles nested parentheses and quoted strings. + /// + private static List ParsePluginCallArguments(string argsContent) + { + var result = new List(); + int i = 0; + int argCount = 0; + + while (i < argsContent.Length && argCount < 3) + { + // Skip whitespace + while (i < argsContent.Length && char.IsWhiteSpace(argsContent[i])) i++; + if (i >= argsContent.Length) break; + + char c = argsContent[i]; + if (c == '"') + { + // String literal + int start = i; + i++; + while (i < argsContent.Length) + { + if (argsContent[i] == '\\' && i + 1 < argsContent.Length) + i += 2; // Skip escaped char + else if (argsContent[i] == '"') + { + i++; + break; + } + else + i++; + } + result.Add(argsContent[(start + 1)..(i - 1)]); // Extract content between quotes (exclude both '"') + argCount++; + } + else if (c == '(') + { + // Nested call - skip to matching ')' + int depth = 1; + i++; + while (i < argsContent.Length && depth > 0) + { + if (argsContent[i] == '(') depth++; + else if (argsContent[i] == ')') depth--; + i++; + } + } + else + { + // Identifier or other - skip to comma or end + while (i < argsContent.Length && argsContent[i] != ',') i++; + // Don't count as an argument (not a string literal) + } + + // Skip to next comma + while (i < argsContent.Length && argsContent[i] != ',') i++; + if (i < argsContent.Length && argsContent[i] == ',') i++; + } + + return result; + } + public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) { if (node is not CallNode call) return null; @@ -83,15 +156,23 @@ public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) ? $"\"{call.TargetDevice}\"" : "null"; - var extraArgPins = call.InputPins - .Where(p => p.Direction == PinDirection.Input && p.Name != "Exec") - .Skip(3) - .ToList(); - - var extraArgs = new List(); - foreach (var pin in extraArgPins) + // Use ExtraArguments if available (set by ConfigureNode), otherwise fall back to InputPins + List extraArgs; + if (call.ExtraArguments.Count > 0) + { + extraArgs = call.ExtraArguments; + } + else { - extraArgs.Add(helper.GetInputValue(call, pin.Name)); + extraArgs = new List(); + var extraArgPins = call.InputPins + .Where(p => p.Direction == PinDirection.Input && p.Name != "Exec") + .Skip(3) + .ToList(); + foreach (var pin in extraArgPins) + { + extraArgs.Add(helper.GetInputValue(call, pin.Name)); + } } string expression; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PrintFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PrintFunction.cs index 38c6f031..2d22cbb3 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PrintFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PrintFunction.cs @@ -2,6 +2,7 @@ using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; using KitX.Core.Workflow.Blueprint; +using KitX.Core.Workflow.Blueprint.CFG; using KitX.Core.Workflow.Blueprint.Pipeline; namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions @@ -16,7 +17,7 @@ public class PrintFunction : IBuiltinFunctionDefinition public bool IsFlowControl => false; public bool IsNonExtractable => true; public BlueprintNodeType? LegacyNodeType => BlueprintNodeType.Print; - public FormattedStatementKind StatementKind => FormattedStatementKind.Print; + public CFGStatementKind StatementKind => CFGStatementKind.Print; public double NodeWidth => 100; public double NodeHeight => 50; @@ -39,7 +40,7 @@ public List FormatInvocation( return [new FormattedStatement { BlockName = blockName, - Kind = FormattedStatementKind.Print, + Kind = CFGStatementKind.Print, FunctionName = FunctionName, Arguments = args, OriginalExpression = invoke.ToString(), diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/SetFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/SetFunction.cs index f38d891b..d91f0a79 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/SetFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/SetFunction.cs @@ -3,6 +3,7 @@ using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; using KitX.Core.Workflow.Blueprint; +using KitX.Core.Workflow.Blueprint.CFG; using KitX.Core.Workflow.Blueprint.Pipeline; namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions @@ -17,7 +18,7 @@ public class SetFunction : IBuiltinFunctionDefinition public bool IsFlowControl => false; public bool IsNonExtractable => true; public BlueprintNodeType? LegacyNodeType => BlueprintNodeType.Set; - public FormattedStatementKind StatementKind => FormattedStatementKind.Set; + public CFGStatementKind StatementKind => CFGStatementKind.Set; public double NodeWidth => 120; public double NodeHeight => 60; @@ -50,7 +51,7 @@ public List FormatInvocation( return [new FormattedStatement { BlockName = blockName, - Kind = FormattedStatementKind.Set, + Kind = CFGStatementKind.Set, FunctionName = FunctionName, SetVarName = setVarName, Arguments = currentArgExprs, diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/ToLoopCondFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/ToLoopCondFunction.cs index 75545078..8324a8f1 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/ToLoopCondFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/ToLoopCondFunction.cs @@ -2,6 +2,7 @@ using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; using KitX.Core.Workflow.Blueprint; +using KitX.Core.Workflow.Blueprint.CFG; using KitX.Core.Workflow.Blueprint.Pipeline; namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions @@ -17,7 +18,7 @@ public class ToLoopCondFunction : IBuiltinFunctionDefinition public bool IsFlowControl => true; public bool IsNonExtractable => false; public bool IsBlockTerminator => true; - public FormattedStatementKind StatementKind => FormattedStatementKind.ToLoopCond; + public CFGStatementKind StatementKind => CFGStatementKind.ToLoopCond; public double NodeWidth => 80; public double NodeHeight => 60; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/TryGetDeviceFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/TryGetDeviceFunction.cs index 1a9a05ed..ec76a73b 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/TryGetDeviceFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/TryGetDeviceFunction.cs @@ -6,9 +6,11 @@ using KitX.Core.Contract.Workflow; using KitX.Core.Device; using KitX.Core.Workflow.Blueprint; +using KitX.Core.Workflow.Blueprint.CFG; using KitX.Core.Workflow.Blueprint.Pipeline; using KitX.Shared.CSharp.Device; using Serilog; +using Microsoft.Extensions.DependencyInjection; namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions { @@ -25,7 +27,7 @@ public class TryGetDeviceFunction : IBuiltinFunctionDefinition public bool IsFlowControl => false; public bool IsNonExtractable => false; public BlueprintNodeType? LegacyNodeType => null; - public FormattedStatementKind StatementKind => FormattedStatementKind.TryGetDevice; + public CFGStatementKind StatementKind => CFGStatementKind.TryGetDevice; public double NodeWidth => 120; public double NodeHeight => 60; @@ -64,7 +66,7 @@ public List FormatInvocation( return [new FormattedStatement { BlockName = blockName, - Kind = FormattedStatementKind.TryGetDevice, + Kind = CFGStatementKind.TryGetDevice, FunctionName = FunctionName, PubVarTarget = pubVarTarget, Arguments = args, diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/IBuiltinFunctionDefinition.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/IBuiltinFunctionDefinition.cs index 79394eab..41c3374a 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/IBuiltinFunctionDefinition.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/IBuiltinFunctionDefinition.cs @@ -2,6 +2,7 @@ using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; using KitX.Core.Workflow.Blueprint; +using KitX.Core.Workflow.Blueprint.CFG; using KitX.Core.Workflow.Blueprint.Pipeline; namespace KitX.Core.Workflow.BlockScripting; @@ -34,10 +35,10 @@ public interface IBuiltinFunctionDefinition // ─── 语句类型映射 ───────────────────────────────── /// - /// 对应的 FormattedStatementKind。用于 ScriptFormatter 确定语句类型, + /// 对应的 CFGStatementKind。用于 ScriptFormatter 确定语句类型, /// 以及 NodeBuilder 选择节点创建策略。 /// - FormattedStatementKind StatementKind { get; } + CFGStatementKind StatementKind { get; } // ─── 语句字段提取(可选,默认无操作)────────────── diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptCodeGenerator.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptCodeGenerator.cs index afff66fc..5010aa9b 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptCodeGenerator.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptCodeGenerator.cs @@ -8,6 +8,8 @@ using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; +using KitX.Core.Workflow.Blueprint; +using KitX.Core.Workflow.Blueprint.CFG; using KitX.Core.Workflow.Blueprint.Pipeline; using Serilog; @@ -49,6 +51,10 @@ internal static Dictionary InferPubVarTypes( foreach (var name in context.PubVarNames) pubVarTypes[name] = "object"; + // Add ConstBlock variables (accessible as identifiers in expressions) + foreach (var kvp in context.ConstNodes) + pubVarTypes[kvp.Key] = kvp.Value.ConstType ?? "object"; + // First pass: SOURCE types foreach (var block in formattedScript.Blocks) { @@ -73,7 +79,7 @@ internal static Dictionary InferPubVarTypes( foreach (var stmt in block.Statements) { // Branch/Loop condition demands bool - if ((stmt.Kind == FormattedStatementKind.Branch || stmt.Kind == FormattedStatementKind.Loop) + if ((stmt.Kind == CFGStatementKind.Branch || stmt.Kind == CFGStatementKind.Loop) && !string.IsNullOrEmpty(stmt.ConditionPubVar) && pubVarTypes.ContainsKey(stmt.ConditionPubVar)) { @@ -82,7 +88,7 @@ internal static Dictionary InferPubVarTypes( } // Helper function arguments demand specific types - if ((stmt.Kind == FormattedStatementKind.Assignment || stmt.Kind == FormattedStatementKind.Expression) + if ((stmt.Kind == CFGStatementKind.Assignment || stmt.Kind == CFGStatementKind.Expression) && stmt.FunctionName != null && helperMap.TryGetValue(stmt.FunctionName, out var consumerHelper)) { @@ -510,8 +516,8 @@ internal static SwitchSectionSyntax GenerateFormattedBlockCase( { switch (stmt.Kind) { - case FormattedStatementKind.Assignment: - case FormattedStatementKind.Expression: + case CFGStatementKind.Assignment: + case CFGStatementKind.Expression: { ExpressionSyntax rawExpr; string sourceType = "object"; @@ -563,7 +569,7 @@ internal static SwitchSectionSyntax GenerateFormattedBlockCase( break; } - case FormattedStatementKind.Branch: + case CFGStatementKind.Branch: { var condExpr = !string.IsNullOrEmpty(stmt.ConditionPubVar) ? ResolveArgumentExpression(stmt.ConditionPubVar, pubVarTypes) @@ -590,7 +596,7 @@ internal static SwitchSectionSyntax GenerateFormattedBlockCase( break; } - case FormattedStatementKind.Loop: + case CFGStatementKind.Loop: { var condExpr = !string.IsNullOrEmpty(stmt.ConditionPubVar) ? ResolveArgumentExpression(stmt.ConditionPubVar, pubVarTypes) @@ -617,7 +623,7 @@ internal static SwitchSectionSyntax GenerateFormattedBlockCase( break; } - case FormattedStatementKind.Print: + case CFGStatementKind.Print: { if (stmt.Arguments.Count > 0) { @@ -633,7 +639,7 @@ internal static SwitchSectionSyntax GenerateFormattedBlockCase( break; } - case FormattedStatementKind.PluginCallWithTarget: + case CFGStatementKind.PluginCallWithTarget: { var args = new List(); @@ -665,7 +671,7 @@ internal static SwitchSectionSyntax GenerateFormattedBlockCase( break; } - case FormattedStatementKind.TryGetDevice: + case CFGStatementKind.TryGetDevice: { if (stmt.Arguments.Count > 0 && !string.IsNullOrEmpty(stmt.PubVarTarget)) { @@ -686,7 +692,7 @@ internal static SwitchSectionSyntax GenerateFormattedBlockCase( break; } - case FormattedStatementKind.Set: + case CFGStatementKind.Set: { var varName = stmt.SetVarName ?? ""; if (stmt.Arguments.Count > 0) @@ -707,7 +713,7 @@ internal static SwitchSectionSyntax GenerateFormattedBlockCase( break; } - case FormattedStatementKind.ToLoopCond: + case CFGStatementKind.ToLoopCond: { caseStatements.Add( ExpressionStatement( @@ -728,13 +734,13 @@ internal static SwitchSectionSyntax GenerateFormattedBlockCase( break; } - case FormattedStatementKind.Break: + case CFGStatementKind.Break: { caseStatements.Add(ReturnStatement()); break; } - case FormattedStatementKind.Pause: + case CFGStatementKind.Pause: { if (stmt.Arguments.Count > 0) { diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGBuilderFromBlueprint.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGBuilderFromBlueprint.cs index ed263266..df1d8c58 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGBuilderFromBlueprint.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGBuilderFromBlueprint.cs @@ -811,9 +811,12 @@ private static void SetParentLoopReferences(ControlFlowGraph cfg) var pluginNameLit = $"\"{call.PluginName}\""; var methodNameLit = $"\"{call.FunctionName}\""; var targetDeviceLit = $"\"{call.TargetDevice}\""; - sourceCode = string.IsNullOrEmpty(callArgs) - ? $"PluginCallWithTarget({pluginNameLit}, {methodNameLit}, {targetDeviceLit})" - : $"PluginCallWithTarget({pluginNameLit}, {methodNameLit}, {targetDeviceLit}, {callArgs})"; + // Include ExtraArguments (stored by ConfigureNode from PluginCallWithTarget's extra params) + var extraArgs = call.ExtraArguments.Count > 0 + ? ", " + string.Join(", ", call.ExtraArguments.Select( + arg => NodeExportHelper.FormatLiteralValue(arg, _currentCtx))) + : ""; + sourceCode = $"PluginCallWithTarget({pluginNameLit}, {methodNameLit}, {targetDeviceLit}{extraArgs})"; } else { diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGBuilderFromScript.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGBuilderFromScript.cs index b3195ec7..36d3b6c6 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGBuilderFromScript.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGBuilderFromScript.cs @@ -120,7 +120,7 @@ private static CFGStatement ConvertStatement(Pipeline.FormattedStatement fmtStmt { Id = fmtStmt.StatementId, BlockName = fmtStmt.BlockName, - Kind = ConvertKind(fmtStmt.Kind), + Kind = fmtStmt.Kind, OriginalExpression = fmtStmt.OriginalExpression, SourceLine = fmtStmt.SourceLine, PubVarTarget = fmtStmt.PubVarTarget, @@ -139,22 +139,6 @@ private static CFGStatement ConvertStatement(Pipeline.FormattedStatement fmtStmt }; } - private static CFGStatementKind ConvertKind(Pipeline.FormattedStatementKind kind) => kind switch - { - Pipeline.FormattedStatementKind.Print => CFGStatementKind.Print, - Pipeline.FormattedStatementKind.Pause => CFGStatementKind.Pause, - Pipeline.FormattedStatementKind.Set => CFGStatementKind.Set, - Pipeline.FormattedStatementKind.Get => CFGStatementKind.Get, - Pipeline.FormattedStatementKind.Assignment => CFGStatementKind.Assignment, - Pipeline.FormattedStatementKind.Branch => CFGStatementKind.Branch, - Pipeline.FormattedStatementKind.Loop => CFGStatementKind.Loop, - Pipeline.FormattedStatementKind.ToLoopCond => CFGStatementKind.ToLoopCond, - Pipeline.FormattedStatementKind.Break => CFGStatementKind.Break, - Pipeline.FormattedStatementKind.NextBlockAssignment => CFGStatementKind.NextBlockAssignment, - Pipeline.FormattedStatementKind.Expression => CFGStatementKind.Expression, - _ => CFGStatementKind.Unknown, - }; - // ─── Block Type Classification ────────────────────────────────────── private static CFGBlockType ClassifyBlockType(Pipeline.FormattedBlock fmtBlock) @@ -169,8 +153,8 @@ private static CFGBlockType ClassifyBlockType(Pipeline.FormattedBlock fmtBlock) var lastStmt = fmtBlock.Statements[^1]; return lastStmt.Kind switch { - Pipeline.FormattedStatementKind.Branch => CFGBlockType.BranchHeader, - Pipeline.FormattedStatementKind.Loop => CFGBlockType.LoopHeader, + CFGStatementKind.Branch => CFGBlockType.BranchHeader, + CFGStatementKind.Loop => CFGBlockType.LoopHeader, _ => CFGBlockType.Basic }; } @@ -196,7 +180,7 @@ private static void BuildEdgesFromBlock(CFGBlock cfgBlock, Pipeline.FormattedBlo { switch (stmt.Kind) { - case Pipeline.FormattedStatementKind.Branch: + case CFGStatementKind.Branch: if (!string.IsNullOrEmpty(stmt.TrueBlockName)) cfgBlock.Successors.Add(new CFGEdge { @@ -215,7 +199,7 @@ private static void BuildEdgesFromBlock(CFGBlock cfgBlock, Pipeline.FormattedBlo }); break; - case Pipeline.FormattedStatementKind.Loop: + case CFGStatementKind.Loop: if (!string.IsNullOrEmpty(stmt.TrueBlockName)) cfgBlock.Successors.Add(new CFGEdge { @@ -234,7 +218,7 @@ private static void BuildEdgesFromBlock(CFGBlock cfgBlock, Pipeline.FormattedBlo }); break; - case Pipeline.FormattedStatementKind.ToLoopCond: + case CFGStatementKind.ToLoopCond: if (!string.IsNullOrEmpty(stmt.ToLoopCondReturnTo)) cfgBlock.Successors.Add(new CFGEdge { @@ -245,7 +229,7 @@ private static void BuildEdgesFromBlock(CFGBlock cfgBlock, Pipeline.FormattedBlo }); break; - case Pipeline.FormattedStatementKind.Break: + case CFGStatementKind.Break: // Break edges are resolved later during BP→BS conversion cfgBlock.Successors.Add(new CFGEdge { diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGStatement.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGStatement.cs index d38099ef..2231b658 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGStatement.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGStatement.cs @@ -1,10 +1,9 @@ namespace KitX.Core.Workflow.Blueprint.CFG; /// -/// Kinds of statements in the CFG. Mirrors -/// but with stronger semantics — every statement in the CFG is classified. +/// Kinds of statements in the CFG. Unified statement kind replacing the former Pipeline.FormattedStatementKind. /// -internal enum CFGStatementKind +public enum CFGStatementKind { /// Unknown or unclassified Unknown, @@ -41,6 +40,15 @@ internal enum CFGStatementKind /// Plain expression without assignment Expression, + + /// PluginCallWithTarget(pluginName, methodName, targetDevice, args...) — cross-device plugin call + PluginCallWithTarget, + + /// TryGetDevice(deviceSearchPattern) — returns DeviceInfo or null + TryGetDevice, + + /// PluginCall(pluginName, methodName[, args...]) — local plugin call + PluginCall, } /// diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/DataEdgeBuilder.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/DataEdgeBuilder.cs index 2cb50b43..9f64143c 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/DataEdgeBuilder.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/DataEdgeBuilder.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using KitX.Core.Contract.Workflow; +using KitX.Core.Workflow.Blueprint.CFG; using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Pins; using Serilog; @@ -40,28 +41,28 @@ private void ProcessStatement(FormattedStatement stmt, PipelineContext context) { switch (stmt.Kind) { - case FormattedStatementKind.Assignment: - case FormattedStatementKind.Expression: + case CFGStatementKind.Assignment: + case CFGStatementKind.Expression: ProcessCallArguments(stmt, context); break; - case FormattedStatementKind.Print: + case CFGStatementKind.Print: ProcessSingleValueInput(stmt, stmt.Arguments, Value, context); break; - case FormattedStatementKind.Set: + case CFGStatementKind.Set: ProcessSetValue(stmt, context); break; - case FormattedStatementKind.Pause: + case CFGStatementKind.Pause: ProcessSingleValueInput(stmt, stmt.Arguments, "Milliseconds", context); break; - case FormattedStatementKind.Branch: + case CFGStatementKind.Branch: ProcessConditionInput(stmt, context); break; - case FormattedStatementKind.Loop: + case CFGStatementKind.Loop: ProcessConditionInput(stmt, context); break; } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/FormattedBlockScript.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/FormattedBlockScript.cs index 7ef3a927..5b8026c3 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/FormattedBlockScript.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/FormattedBlockScript.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using KitX.Core.Workflow.Blueprint.CFG; using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Blocks; @@ -46,7 +47,7 @@ public class FormattedStatement /// /// What kind of statement this is. /// - public FormattedStatementKind Kind { get; set; } + public CFGStatementKind Kind { get; set; } // --- For Assignment / Get --- /// @@ -105,54 +106,3 @@ public class FormattedStatement /// public string? Fingerprint { get; set; } } - -/// -/// Kinds of formatted statements. -/// -public enum FormattedStatementKind -{ - /// Unknown or unclassified - Unknown, - - /// Print(expr) - Print, - - /// Pause(ms) - Pause, - - /// Set("varName", expr) - Set, - - /// Get("varName") — standalone or as part of a PubVar assignment - Get, - - /// pubVar = FunctionCall(args...) - Assignment, - - /// Branch(condition, trueBlock, falseBlock) - Branch, - - /// Loop(condition, loopBody, afterLoop) - Loop, - - /// ToLoopCond("parentBlock") - ToLoopCond, - - /// Break() - Break, - - /// NextBlock = ... (handled internally, no node created) - NextBlockAssignment, - - /// Plain expression without assignment - Expression, - - /// PluginCallWithTarget(pluginName, methodName, targetDevice, args...) — cross-device plugin call - PluginCallWithTarget, - - /// TryGetDevice(deviceSearchPattern) — returns DeviceInfo or null - TryGetDevice, - - /// PluginCall(pluginName, methodName[, args...]) — local plugin call - PluginCall, -} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/NodeBuilder.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/NodeBuilder.cs index c48bffe9..99600246 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/NodeBuilder.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/NodeBuilder.cs @@ -3,6 +3,7 @@ using System.Linq; using KitX.Core.Contract.Workflow; using KitX.Core.Workflow.BlockScripting; +using KitX.Core.Workflow.Blueprint.CFG; using Serilog; using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Pins; @@ -85,10 +86,10 @@ private void ProcessBlock(FormattedBlock block, PipelineContext context) if (node != null) context.BlockNodeIds[block.Name].Add(node.Id); - endsWithFlowCtrl = stmt.Kind is FormattedStatementKind.Branch - or FormattedStatementKind.Loop - or FormattedStatementKind.ToLoopCond - or FormattedStatementKind.Break + endsWithFlowCtrl = stmt.Kind is CFGStatementKind.Branch + or CFGStatementKind.Loop + or CFGStatementKind.ToLoopCond + or CFGStatementKind.Break || IsRegistryFlowControlTerminator(stmt); } @@ -131,13 +132,15 @@ or FormattedStatementKind.Break // Non-terminator statement handling switch (stmt.Kind) { - case FormattedStatementKind.Assignment: - case FormattedStatementKind.Expression: + case CFGStatementKind.Assignment: + case CFGStatementKind.Expression: return ProcessCallOrAssignment(stmt, context, ref prevNode, ref prevStmtId); - case FormattedStatementKind.Print: - case FormattedStatementKind.Pause: - case FormattedStatementKind.Set: + case CFGStatementKind.Print: + case CFGStatementKind.Pause: + case CFGStatementKind.Set: + case CFGStatementKind.PluginCall: + case CFGStatementKind.PluginCallWithTarget: { // Use registry to determine the legacy node type and configure it if (_functionRegistry != null && !string.IsNullOrEmpty(stmt.FunctionName) @@ -151,9 +154,11 @@ or FormattedStatementKind.Break // Fallback without registry (should not happen in production) var fallbackType = stmt.Kind switch { - FormattedStatementKind.Print => BlueprintNodeType.Print, - FormattedStatementKind.Pause => BlueprintNodeType.Pause, - FormattedStatementKind.Set => BlueprintNodeType.Set, + CFGStatementKind.Print => BlueprintNodeType.Print, + CFGStatementKind.Pause => BlueprintNodeType.Pause, + CFGStatementKind.Set => BlueprintNodeType.Set, + CFGStatementKind.PluginCall => BlueprintNodeType.Call, + CFGStatementKind.PluginCallWithTarget => BlueprintNodeType.Call, _ => BlueprintNodeType.Call }; var fallbackNode = _registry.Create(fallbackType); diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ScriptFormatter.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ScriptFormatter.cs index 5bcbf93d..6117ea34 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ScriptFormatter.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ScriptFormatter.cs @@ -5,6 +5,7 @@ using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; using KitX.Core.Workflow.BlockScripting; +using KitX.Core.Workflow.Blueprint.CFG; using Serilog; using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Functions; @@ -109,7 +110,7 @@ private List FormatFlowControl(FlowControlStatement flowCtrl result.Add(new FormattedStatement { BlockName = blockName, - Kind = FormattedStatementKind.Branch, + Kind = CFGStatementKind.Branch, FunctionName = Branch, ConditionPubVar = condPubVar, ConditionExpression = flowCtrl.ConditionExpression, @@ -129,7 +130,7 @@ private List FormatFlowControl(FlowControlStatement flowCtrl var loopStmt = new FormattedStatement { BlockName = blockName, - Kind = FormattedStatementKind.Loop, + Kind = CFGStatementKind.Loop, FunctionName = Loop, ConditionPubVar = condPubVar, ConditionExpression = flowCtrl.ConditionExpression, @@ -157,7 +158,7 @@ private List FormatFlowControl(FlowControlStatement flowCtrl result.Add(new FormattedStatement { BlockName = blockName, - Kind = FormattedStatementKind.ToLoopCond, + Kind = CFGStatementKind.ToLoopCond, FunctionName = ToLoopCond, ToLoopCondReturnTo = flowCtrl.ToLoopCondReturnTo, OriginalExpression = flowCtrl.SourceCode, @@ -169,7 +170,7 @@ private List FormatFlowControl(FlowControlStatement flowCtrl result.Add(new FormattedStatement { BlockName = blockName, - Kind = FormattedStatementKind.Break, + Kind = CFGStatementKind.Break, FunctionName = Break, OriginalExpression = flowCtrl.SourceCode, SourceLine = flowCtrl.LineNumber @@ -233,7 +234,7 @@ private List FormatInvocation( result.AddRange(expansionStmts); // Determine statement kind and extract info - FormattedStatementKind kind; + CFGStatementKind kind; string? pubVarTarget = null; string? setVarName = null; string? getVarName = null; @@ -249,13 +250,21 @@ private List FormatInvocation( setVarName = sn; getVarName = gn; pubVarTarget = pv; + // If ExtractStatementFields didn't set PubVarTarget but we have an assignment context + // and the function produces a value (non-void return), preserve the assignment + if (pubVarTarget == null && !string.IsNullOrEmpty(assignedVar) && assignedVar != "_") + { + pubVarTarget = assignedVar; + if (!context.PubVarNames.Contains(assignedVar)) + context.PubVarNames.Add(assignedVar); + } } else { // Helper or regular function call if (!string.IsNullOrEmpty(assignedVar) && assignedVar != "_") { - kind = FormattedStatementKind.Assignment; + kind = CFGStatementKind.Assignment; pubVarTarget = assignedVar; // Ensure the assigned variable is tracked as a PubVar if (!context.PubVarNames.Contains(assignedVar)) @@ -263,13 +272,13 @@ private List FormatInvocation( } else { - kind = FormattedStatementKind.Expression; + kind = CFGStatementKind.Expression; } } break; } - var fingerprint = kind is FormattedStatementKind.Assignment or FormattedStatementKind.Expression + var fingerprint = kind is CFGStatementKind.Assignment or CFGStatementKind.Expression ? ExprUtils.ComputeFingerprint(funcName, currentArgExprs) : null; @@ -379,7 +388,7 @@ private List FormatInvocation( allStmts.Add(new FormattedStatement { BlockName = blockName, - Kind = FormattedStatementKind.Assignment, + Kind = CFGStatementKind.Assignment, PubVarTarget = pubVarName, FunctionName = funcName, FullFunctionName = fullFuncName, diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/RealPluginManager.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/RealPluginManager.cs index d9b1a2f8..353106a8 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/RealPluginManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/RealPluginManager.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Concurrent; using System.Linq; using System.Net.Http; @@ -12,11 +12,13 @@ using KitX.Core.Device; using KitX.Core.Contract.Plugin.Events; using KitX.Core.Device.Events; +using KitX.Core.Event; using KitX.Shared.CSharp.Device; using KitX.Shared.CSharp.Plugin; using KitX.Shared.CSharp.WebCommand; using KitX.Shared.CSharp.WebCommand.Infos; using Serilog; +using Microsoft.Extensions.DependencyInjection; using KcsPluginCallInfo = Kscript.CSharp.Parser.Models.PluginCallInfo; using KcsIPluginManager = Kscript.CSharp.Parser.Core.IPluginManager; @@ -74,7 +76,12 @@ public RealPluginManager(PluginsServer pluginsServer, IDeviceHttpClient deviceHt _pluginsServer.PluginMessageReceived += OnPluginMessageReceived; // 订阅插件响应事件(当插件返回带RequestId的响应时触发) - _pluginsServer.PluginResponse += OnPluginResponse; + // IMPORTANT: Subscribe to EventService.PluginResponse instead of PluginsServer.PluginResponse, + // because PluginsServer forwards PluginConnection.PluginResponse to EventService, not to PluginsServer.PluginResponse + if (EventService.Instance != null) + { + EventService.Instance.Subscribe(EventNames.PluginResponse, (sender, args) => OnPluginResponse(this, args)); + } } /// @@ -545,8 +552,20 @@ public bool IsMethodExists(string pluginName, string methodName) /// private IPluginConnection? FindPluginConnection(string pluginName) { - return _pluginsServer.Connections + Log.Information($"[RealPluginManager] FindPluginConnection: _pluginsServer HashCode={_pluginsServer.GetHashCode()}, Connections Count={_pluginsServer.Connections.Count}"); + Log.Information($"[RealPluginManager] FindPluginConnection: PluginsServer.Instance HashCode={PluginsServer.Instance.GetHashCode()}"); + if (_pluginsServer.GetHashCode() != PluginsServer.Instance.GetHashCode()) + { + Log.Warning($"[RealPluginManager] WARNING: _pluginsServer ({_pluginsServer.GetHashCode()}) != PluginsServer.Instance ({PluginsServer.Instance.GetHashCode()})!"); + } + foreach (var c in _pluginsServer.Connections) + { + Log.Information($"[RealPluginManager] FindPluginConnection: connection PluginInfo.Name={c.PluginInfo?.Name}"); + } + var result = _pluginsServer.Connections .FirstOrDefault(c => c.PluginInfo?.Name == pluginName); + Log.Information($"[RealPluginManager] FindPluginConnection result: {result?.GetHashCode()}"); + return result; } /// diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/TriggerManager.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/TriggerManager.cs index f20765b0..a797a0ed 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/TriggerManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/TriggerManager.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Text.Json; +using System.Threading.Tasks; using KitX.Core.Contract.Workflow; using KitX.Core.Contract.Device; using KitX.Core.Device; @@ -9,6 +10,7 @@ using KitX.Shared.CSharp.WebCommand; using KitX.Shared.CSharp.WebCommand.Infos; using Serilog; +using KitX.Core.DI; namespace KitX.Core.Workflow; @@ -18,9 +20,27 @@ namespace KitX.Core.Workflow; /// public class TriggerManager { - private static TriggerManager? _instance; + /// + /// Gets the singleton instance (resolves from ServiceHost when available). + /// Internal code should use constructor injection instead. + /// + public static TriggerManager Instance + { + get + { + if (ServiceHost.IsInitialized) + return ServiceHost.GetRequiredService(); + Log.Error("[TriggerManager] Instance: ServiceHost not initialized! Returning orphan instance — " + + "this indicates a DI initialization order bug. Use ServiceHost/constructor injection instead."); + return new TriggerManager(); + } + } - internal static TriggerManager Instance => _instance ??= new(); + /// + /// Kept for backward compatibility — ServiceHost is now the single source of truth. + /// + [Obsolete("ServiceHost is now the single source of truth. This method is a no-op.")] + internal static void SetServiceProvider(IServiceProvider? sp) { /* no-op */ } private readonly PluginsServer _pluginsServer; private readonly JsonSerializerOptions _serializerOptions = new() @@ -40,7 +60,10 @@ public class TriggerManager /// private readonly Dictionary _workflowTriggers = new(); - private TriggerManager() + /// + /// Creates a new trigger manager + /// + public TriggerManager() { _pluginsServer = PluginsServer.Instance; _pluginsServer.PluginMessageReceived += OnPluginMessageReceived; @@ -78,6 +101,43 @@ public void UnregisterWorkflowTrigger(string workflowId) } } + /// + /// Initialize trigger subscriptions from persisted workflow configurations. + /// Should be called once after DI initialization is complete, before plugins connect. + /// This ensures that TriggerFired events from plugins can be routed to the correct workflows + /// even if they arrive before the user opens the workflow page. + /// + public async void InitializeFromPersistedWorkflows() + { + try + { + if (!ServiceHost.IsInitialized) + { + Log.Error("[TriggerManager] Cannot initialize from persisted workflows: ServiceHost not initialized"); + return; + } + + var storageService = ServiceHost.GetRequiredService(); + var workflows = await storageService.DiscoverWorkflowsAsync(); + + foreach (var workflow in workflows) + { + if (workflow.TriggerConfig != null + && !string.IsNullOrEmpty(workflow.TriggerConfig.PluginName)) + { + RegisterWorkflowTrigger(workflow.Id, workflow.TriggerConfig); + } + } + + Log.Information("[TriggerManager] Initialized {Count} trigger subscriptions from persisted workflows", + _triggerSubscriptions.Count); + } + catch (Exception ex) + { + Log.Error(ex, "[TriggerManager] Failed to initialize from persisted workflows"); + } + } + /// /// 重建订阅索引(在配置变更时调用) /// @@ -144,7 +204,7 @@ private void OnPluginMessageReceived(object? sender, PluginMessageReceivedEventA Log.Information("[TriggerManager] Triggering workflow: {WorkflowId}", workflowId); _ = System.Threading.Tasks.Task.Run(async () => { - bool success = await WorkflowScriptService.Instance.RunWorkflowAsync(workflowId); + bool success = await ServiceHost.GetRequiredService().RunWorkflowAsync(workflowId); KitX.Core.Event.EventService.Instance.Publish( KitX.Core.Event.EventNames.WorkflowExecutionResult, new KitX.Core.Contract.Event.WorkflowExecutionResultEventArgs( diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowRuntimeState.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowRuntimeState.cs index 32628db5..19771702 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowRuntimeState.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowRuntimeState.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using Csharpell.Core; using KitX.Core.Contract.Workflow; using KitX.Core.Workflow.BlockScripting; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs index 8e1096bd..f9f543c4 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs @@ -1,9 +1,10 @@ -using System; +using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using KitX.Core.Contract.Workflow; using KitX.Core.Device; +using KitX.Core.DI; using KitX.Shared.CSharp.Plugin; using Serilog; @@ -16,12 +17,19 @@ namespace KitX.Core.Workflow; public class WorkflowScriptService : IWorkflowManagementService, IScriptExecutionService, IWorkflowPluginService, IBlockScriptService { - private static WorkflowScriptService? _instance; + /// + /// Gets the singleton facade instance. + /// WorkflowScriptService is a facade that delegates to its static service graph, + /// so it's not resolved from DI (IBlockScriptService is registered as BlockScriptServiceImpl). + /// Use ServiceHost.GetRequiredService<IWorkflowManagementService>() etc. for individual interfaces. + /// + public static WorkflowScriptService Instance { get; } = new(); /// - /// Gets the singleton instance. + /// Kept for backward compatibility — ServiceHost is now the single source of truth. /// - internal static WorkflowScriptService Instance => _instance ??= new(); + [Obsolete("ServiceHost is now the single source of truth. This method is a no-op.")] + internal static void SetServiceProvider(IServiceProvider? sp) { /* no-op */ } /// /// Shared runtime state across all workflow services. @@ -31,40 +39,60 @@ public class WorkflowScriptService : IWorkflowManagementService, IScriptExecutio /// /// Service graph — built once at singleton instantiation. /// - private static readonly IBlockScriptService BlockScriptService; + private static IBlockScriptService? _blockScriptService; + private static IWorkflowManagementService? _managementService; private static readonly IWorkflowPluginService PluginService; private static readonly IScriptExecutionService ScriptExecutionService; - private static readonly IWorkflowManagementService ManagementService; /// /// Static constructor initializes the service graph in dependency order. + /// Note: BlockScriptService is NOT initialized here to avoid accessing it before + /// PreResolvedRealPluginManager is set. BlockScriptService is fully lazy-initialized + /// via its property getter. /// static WorkflowScriptService() { - // 1. BlockScriptService (no cross-service dependencies) - BlockScriptService = new BlockScriptServiceImpl(SharedState); - - // 2. PluginService (depends only on SharedState) + // 1. PluginService (depends only on SharedState) PluginService = new WorkflowPluginService(SharedState); - // 3. ScriptExecutionService (depends on PluginService) + // 2. ScriptExecutionService (depends on PluginService) ScriptExecutionService = new ScriptExecutionService(SharedState, PluginService); - // 4. ManagementService (depends on BlockScriptService) - ManagementService = new WorkflowManagementService(SharedState, BlockScriptService); + // 3. ManagementService - will be initialized lazily when first accessed + // BlockScriptService is also lazy-initialized, so ManagementService should not + // be created here to avoid using a partially initialized BlockScriptService + } - // Pre-initialize RealPluginManager at startup - try - { - var pluginsServer = PluginsServer.Instance; - var realPluginManager = new RealPluginManager(pluginsServer); - Kscript.CSharp.Parser.Parser.SetPluginManager(realPluginManager); - SharedState.IsParserInitialized = true; - Log.Information("[WorkflowScriptService] Real plugin manager pre-initialized at startup"); - } - catch (Exception ex) + /// + /// Gets the BlockScriptService, creating it lazily once RealPluginManager is available. + /// This ensures the BlockScriptService always has a valid RealPluginManager. + /// + private static IBlockScriptService BlockScriptService + { + get { - Log.Warning(ex, "[WorkflowScriptService] Failed to pre-initialize RealPluginManager, will retry on first script execution"); + if (_blockScriptService == null) + { + RealPluginManager? rpm = null; + try + { + rpm = ServiceHost.GetRequiredService(); + if (rpm != null) + { + Kscript.CSharp.Parser.Parser.SetPluginManager(rpm); + SharedState.IsParserInitialized = true; + Log.Information("[WorkflowScriptService] Real plugin manager obtained. HashCode: {HashCode}", rpm.GetHashCode()); + } + } + catch (Exception ex) + { + Log.Error(ex, "[WorkflowScriptService] Failed to get RealPluginManager"); + } + + _blockScriptService = new BlockScriptServiceImpl(SharedState, rpm); + Log.Information("[WorkflowScriptService] BlockScriptService created with RealPluginManager. HashCode: {HashCode}", rpm?.GetHashCode()); + } + return _blockScriptService; } } @@ -88,6 +116,12 @@ static WorkflowScriptService() /// internal static IScriptExecutionService ScriptExecutionServiceInstance => ScriptExecutionService; + /// + /// Gets the ManagementService, creating it lazily once BlockScriptService is available. + /// + private static IWorkflowManagementService ManagementService => + _managementService ??= new WorkflowManagementService(SharedState, BlockScriptService); + /// /// Exposes IWorkflowManagementService for DI registration. /// diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowStorageService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowStorageService.cs index 6033817a..c4df8899 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowStorageService.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowStorageService.cs @@ -5,6 +5,7 @@ using System.Text.Json; using System.Threading.Tasks; using KitX.Core.Contract.Workflow; +using KitX.Core.DI; using Serilog; namespace KitX.Core.Workflow; @@ -14,16 +15,38 @@ namespace KitX.Core.Workflow; /// public class WorkflowStorageService : IWorkflowStorageService { + /// + /// Gets the singleton instance (resolves from ServiceHost when available). + /// Internal code should use constructor injection instead. + /// + public static WorkflowStorageService Instance + { + get + { + if (ServiceHost.IsInitialized) + return (WorkflowStorageService)ServiceHost.GetRequiredService(); + Log.Error("[WorkflowStorageService] Instance: ServiceHost not initialized! Returning orphan instance — " + + "this indicates a DI initialization order bug. Use ServiceHost/constructor injection instead."); + return new WorkflowStorageService(); + } + } + + /// + /// Kept for backward compatibility — ServiceHost is now the single source of truth. + /// + [Obsolete("ServiceHost is now the single source of truth. This method is a no-op.")] + internal static void SetServiceProvider(IServiceProvider? sp) { /* no-op */ } + private static readonly JsonSerializerOptions _jsonOptions = new() { WriteIndented = true }; - private static readonly Lazy _instance = new(() => new()); - public static WorkflowStorageService Instance => _instance.Value; - private readonly string _storageDirectory; + /// + /// Creates a new workflow storage service + /// public WorkflowStorageService() { _storageDirectory = Path.Combine("./Data/", "Workflows"); diff --git a/KitX Clients/KitX Dashboard b/KitX Clients/KitX Dashboard index 4b48a1be..7827baaa 160000 --- a/KitX Clients/KitX Dashboard +++ b/KitX Clients/KitX Dashboard @@ -1 +1 @@ -Subproject commit 4b48a1bec0ce048f6d17a8b8da9ccbf6988074bc +Subproject commit 7827baaaa72c343d04092c55485d5a1c4d2c4784 diff --git a/KitX Standard b/KitX Standard index 7ff6a867..94953899 160000 --- a/KitX Standard +++ b/KitX Standard @@ -1 +1 @@ -Subproject commit 7ff6a86725dbb808ed548dfc6306723d4881c487 +Subproject commit 9495389969c49dc9b9ba23e6278abce392ec0931 From 1a0f3feb40a07cf8e6b85c38797d078a743a7ae0 Mon Sep 17 00:00:00 2001 From: StarInk Date: Mon, 27 Apr 2026 15:33:16 +0200 Subject: [PATCH 060/110] =?UTF-8?q?=F0=9F=94=A7=20Fix(Core):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E5=8A=A0=E5=AF=86=E3=80=81=E5=AF=86=E9=92=A5=E4=BA=A4?= =?UTF-8?q?=E6=8D=A2=E7=9A=84=E5=AE=89=E5=85=A8=E9=9A=90=E6=82=A3=E4=BB=A5?= =?UTF-8?q?=E5=8F=8A=E5=A2=9E=E5=BC=BA=E6=8F=92=E4=BB=B6=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E6=9C=BA=E5=88=B6=20-=20RSA=20=E9=95=BF=E5=86=85=E5=AE=B9?= =?UTF-8?q?=E5=8A=A0=E5=AF=86/=E8=A7=A3=E5=AF=86=20-=20=E6=B7=B7=E5=90=88?= =?UTF-8?q?=E5=8A=A0=E5=AF=86=E6=96=B9=E6=A1=88=20-=20=E8=AE=BE=E5=A4=87?= =?UTF-8?q?=E5=AF=86=E9=92=A5=E4=BA=A4=E6=8D=A2=E5=AE=89=E5=85=A8=20-=20UI?= =?UTF-8?q?=20=E7=A1=AE=E8=AE=A4=E6=B5=81=E7=A8=8B=20-=20=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F=E7=AE=A1=E7=90=86?= =?UTF-8?q?=20-=20=E5=AE=8C=E6=95=B4=E7=9A=84=E8=BF=9B=E7=A8=8B=E5=90=AF?= =?UTF-8?q?=E5=8A=A8/=E5=81=9C=E6=AD=A2=E9=80=BB=E8=BE=91=20-=20=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E5=87=BD=E6=95=B0=E8=B0=83=E7=94=A8=20-=20WebSocket?= =?UTF-8?q?=20=E8=B0=83=E7=94=A8=E9=80=9A=E9=81=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../KitX.Core/Device/DevicesServer.cs | 159 +++- .../KitX Core/KitX.Core/Event/EventArgs.cs | 24 + .../KitX Core/KitX.Core/Event/EventNames.cs | 6 + .../KitX.Core/Plugin/PluginsManager.cs | 877 +++++++++++++++++- .../KitX.Core/Security/SecurityManager.cs | 105 ++- KitX Clients/KitX Dashboard | 2 +- KitX Standard | 2 +- 7 files changed, 1101 insertions(+), 74 deletions(-) diff --git a/KitX Clients/KitX Core/KitX.Core/Device/DevicesServer.cs b/KitX Clients/KitX Core/KitX.Core/Device/DevicesServer.cs index 04ce7fd4..6c1a5c84 100644 --- a/KitX Clients/KitX Core/KitX.Core/Device/DevicesServer.cs +++ b/KitX Clients/KitX Core/KitX.Core/Device/DevicesServer.cs @@ -70,6 +70,41 @@ internal static void SetServiceProvider(IServiceProvider? sp) { /* no-op */ } /// private string? _exchangeDeviceKeyCode; + /// + /// TaskCompletionSource for awaiting user confirmation on key exchange + /// + private TaskCompletionSource? _exchangeKeyTcs; + + /// + /// Pending exchange key request, stored for later processing after user confirms + /// + private ExchangeKeyRequest? _pendingExchangeRequest; + + /// + /// Number of key exchange attempts in the current rate-limit window + /// + private int _exchangeAttempts; + + /// + /// Start time of the current rate-limit window + /// + private DateTime _exchangeAttemptWindowStart = DateTime.MinValue; + + /// + /// Maximum number of key exchange attempts allowed per rate-limit window + /// + private const int MaxExchangeAttemptsPerWindow = 5; + + /// + /// Duration of the rate-limit window + /// + private static readonly TimeSpan ExchangeRateLimitWindow = TimeSpan.FromMinutes(5); + + /// + /// Timeout for user confirmation of key exchange + /// + private static readonly TimeSpan ExchangeKeyConfirmationTimeout = TimeSpan.FromSeconds(60); + /// /// JSON serializer options for network protocol (compatible with legacy KitX) /// @@ -390,11 +425,28 @@ private async System.Threading.Tasks.Task HandleGetDeviceInfoAsync(HttpContext c /// /// Handles ExchangeKey request (旧架构 API) /// POST /Api/V1/Device/ExchangeKey?verifyCodeSHA1=xxx&address=xxx + /// Requires user confirmation before accepting the key exchange. /// private async System.Threading.Tasks.Task HandleExchangeKeyAsync(HttpContext context) { try { + // Rate limiting check + var now = DateTime.UtcNow; + if (now - _exchangeAttemptWindowStart > ExchangeRateLimitWindow) + { + _exchangeAttempts = 0; + _exchangeAttemptWindowStart = now; + } + if (++_exchangeAttempts > MaxExchangeAttemptsPerWindow) + { + Log.Warning("[DevicesServer] Key exchange rate limit exceeded: {Attempts} attempts in window", + _exchangeAttempts); + context.Response.StatusCode = 429; + await context.Response.WriteAsync("Too many key exchange requests. Please try again later."); + return; + } + if (_isExchangingDeviceKey) { context.Response.StatusCode = 400; @@ -422,24 +474,67 @@ private async System.Threading.Tasks.Task HandleExchangeKeyAsync(HttpContext con return; } + if (request.DeviceKey is null) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync("Device key is null"); + return; + } + // Generate verification code _exchangeDeviceKeyCode = Guid.NewGuid().ToString("N")[..8]; _isExchangingDeviceKey = true; + _pendingExchangeRequest = request; - // Publish event for UI to handle - EventService.Instance.Publish(EventNames.OnReceiveCancelExchangingDeviceKey, EventArgs.Empty); + // Create TaskCompletionSource for user confirmation + _exchangeKeyTcs = new TaskCompletionSource(); - // For now, auto-accept the key exchange (in real implementation, this would show a UI) - // TODO: Integrate with UI for verification code input - if (request.DeviceKey is null) + // Publish event for UI to handle — requires user confirmation + EventService.Instance.Publish(EventNames.OnReceiveExchangeDeviceKey, + new ExchangeDeviceKeyEventArgs + { + VerificationCode = _exchangeDeviceKeyCode, + RequestingDeviceAddress = request.Address ?? string.Empty, + EncryptedDeviceKey = request.DeviceKey + }); + + Log.Information("[DevicesServer] Key exchange request received, waiting for user confirmation. " + + "Verification code: {Code}", _exchangeDeviceKeyCode); + + // Wait for user confirmation with timeout + using var cts = new CancellationTokenSource(ExchangeKeyConfirmationTimeout); + try { + var accepted = await _exchangeKeyTcs.Task.WaitAsync(cts.Token); + + if (!accepted) + { + Log.Information("[DevicesServer] Key exchange rejected by user"); + _isExchangingDeviceKey = false; + _exchangeDeviceKeyCode = null; + _pendingExchangeRequest = null; + context.Response.StatusCode = 403; + await context.Response.WriteAsync("Key exchange rejected by user"); + return; + } + } + catch (OperationCanceledException) + { + Log.Warning("[DevicesServer] Key exchange confirmation timed out after {Timeout}s", + ExchangeKeyConfirmationTimeout.TotalSeconds); _isExchangingDeviceKey = false; _exchangeDeviceKeyCode = null; - context.Response.StatusCode = 400; - await context.Response.WriteAsync("Device key is null"); + _pendingExchangeRequest = null; + context.Response.StatusCode = 408; + await context.Response.WriteAsync("Key exchange confirmation timed out"); return; } + finally + { + _exchangeKeyTcs = null; + } + // User confirmed — proceed with key exchange var deviceKeyDecrypted = securityService.AesDecrypt(request.DeviceKey, _exchangeDeviceKeyCode); var deviceKeyInstance = JsonSerializer.Deserialize(deviceKeyDecrypted); @@ -447,6 +542,7 @@ private async System.Threading.Tasks.Task HandleExchangeKeyAsync(HttpContext con { _isExchangingDeviceKey = false; _exchangeDeviceKeyCode = null; + _pendingExchangeRequest = null; context.Response.StatusCode = 400; await context.Response.WriteAsync("Failed to decrypt device key"); return; @@ -465,6 +561,7 @@ private async System.Threading.Tasks.Task HandleExchangeKeyAsync(HttpContext con { _isExchangingDeviceKey = false; _exchangeDeviceKeyCode = null; + _pendingExchangeRequest = null; context.Response.StatusCode = 500; await context.Response.WriteAsync("Failed to get local key"); return; @@ -475,6 +572,7 @@ private async System.Threading.Tasks.Task HandleExchangeKeyAsync(HttpContext con _isExchangingDeviceKey = false; _exchangeDeviceKeyCode = null; + _pendingExchangeRequest = null; // Publish accept event EventService.Instance.Publish(EventNames.OnAcceptingDeviceKey, EventArgs.Empty); @@ -487,6 +585,9 @@ private async System.Threading.Tasks.Task HandleExchangeKeyAsync(HttpContext con Log.Error(ex, "Error in HandleExchangeKeyAsync"); _isExchangingDeviceKey = false; _exchangeDeviceKeyCode = null; + _pendingExchangeRequest = null; + _exchangeKeyTcs?.TrySetCanceled(); + _exchangeKeyTcs = null; context.Response.StatusCode = 500; await context.Response.WriteAsync($"Error: {ex.Message}"); } @@ -559,13 +660,53 @@ private async System.Threading.Tasks.Task HandleCancelExchangingKeyAsync(HttpCon EventService.Instance.Publish(EventNames.OnReceiveCancelExchangingDeviceKey, EventArgs.Empty); + // Cancel any pending user confirmation + _exchangeKeyTcs?.TrySetCanceled(); + _exchangeKeyTcs = null; + _isExchangingDeviceKey = false; _exchangeDeviceKeyCode = null; + _pendingExchangeRequest = null; context.Response.StatusCode = 200; await context.Response.WriteAsync("OK"); } + /// + /// Accepts a pending key exchange request. Called by UI layer after user confirms. + /// The verification code must match the one displayed to the user. + /// + /// The verification code displayed to the user + /// True if the exchange was accepted successfully, false if no pending exchange or code mismatch + public bool AcceptExchangeKey(string verificationCode) + { + if (!_isExchangingDeviceKey || _exchangeKeyTcs == null) + return false; + + // Verify the code matches to prevent unauthorized acceptance + if (!string.Equals(verificationCode, _exchangeDeviceKeyCode, StringComparison.Ordinal)) + { + Log.Warning("[DevicesServer] Key exchange acceptance failed: verification code mismatch"); + return false; + } + + Log.Information("[DevicesServer] Key exchange accepted by user"); + _exchangeKeyTcs.TrySetResult(true); + return true; + } + + /// + /// Rejects a pending key exchange request. Called by UI layer when user declines. + /// + public void RejectExchangeKey() + { + if (_exchangeKeyTcs == null) + return; + + Log.Information("[DevicesServer] Key exchange rejected by user"); + _exchangeKeyTcs.TrySetResult(false); + } + /// /// Handles Connect request (旧架构 API) /// POST /Api/V1/Device/Connect?deviceBase64=xxx @@ -629,8 +770,8 @@ private async System.Threading.Tasks.Task HandleConnectAsync(HttpContext context // Sign in device var token = SignInDevice(device); - // Encrypt token with local private key - var encryptedToken = securityService.EncryptStringAsync(token, "").Result; + // Encrypt token with the requesting device's public key + var encryptedToken = await securityService.EncryptStringAsync(token, device.MacAddress); context.Response.ContentType = "application/json"; await context.Response.WriteAsync(encryptedToken); diff --git a/KitX Clients/KitX Core/KitX.Core/Event/EventArgs.cs b/KitX Clients/KitX Core/KitX.Core/Event/EventArgs.cs index c900f2f7..4e4bce94 100644 --- a/KitX Clients/KitX Core/KitX.Core/Event/EventArgs.cs +++ b/KitX Clients/KitX Core/KitX.Core/Event/EventArgs.cs @@ -1,4 +1,5 @@ using System; +using KitX.Shared.CSharp.Device; using KitX.Shared.CSharp.Plugin; namespace KitX.Core.Event; @@ -63,3 +64,26 @@ public class PluginConnectionEventArgs : EventArgs /// public PluginInfo? PluginInfo { get; set; } } + +/// +/// Event args for exchange device key request events. +/// Published when a key exchange request is received, requiring user confirmation. +/// +public class ExchangeDeviceKeyEventArgs : EventArgs +{ + /// + /// The verification code displayed to the user for confirmation. + /// This code must match between the two devices for the exchange to proceed. + /// + public string VerificationCode { get; set; } = string.Empty; + + /// + /// The address of the requesting device + /// + public string RequestingDeviceAddress { get; set; } = string.Empty; + + /// + /// The encrypted device key from the request (for informational purposes only, not for UI display) + /// + public string? EncryptedDeviceKey { get; set; } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Event/EventNames.cs b/KitX Clients/KitX Core/KitX.Core/Event/EventNames.cs index ef7d503a..5eb5b3b7 100644 --- a/KitX Clients/KitX Core/KitX.Core/Event/EventNames.cs +++ b/KitX Clients/KitX Core/KitX.Core/Event/EventNames.cs @@ -90,6 +90,12 @@ public static class EventNames /// public const string OnAcceptingDeviceKey = "OnAcceptingDeviceKey"; + /// + /// Receive exchange device key request event. + /// Published when a key exchange request is received, requiring user confirmation. + /// + public const string OnReceiveExchangeDeviceKey = "OnReceiveExchangeDeviceKey"; + /// /// Plugin connected event /// diff --git a/KitX Clients/KitX Core/KitX.Core/Plugin/PluginsManager.cs b/KitX Clients/KitX Core/KitX.Core/Plugin/PluginsManager.cs index 01b61758..65d2cfc2 100644 --- a/KitX Clients/KitX Core/KitX.Core/Plugin/PluginsManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/Plugin/PluginsManager.cs @@ -1,18 +1,26 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Linq; using System.Text; using System.Text.Json; +using System.Threading; using System.Threading.Tasks; using KitX.Core.Contract.Configuration; +using KitX.Core.Contract.Event; using KitX.Core.Contract.Plugin; using KitX.Core.Contract.Plugin.Events; +using KitX.Core.Device; +using KitX.Core.DI; +using KitX.Core.Event; using KitX.Shared.CSharp.Device; using KitX.Shared.CSharp.Loader; using KitX.Shared.CSharp.Plugin; +using KitX.Shared.CSharp.WebCommand; +using KitX.Shared.CSharp.WebCommand.Infos; using Serilog; -using KitX.Core.DI; namespace KitX.Core.Plugin; @@ -45,6 +53,33 @@ internal static void SetServiceProvider(IServiceProvider? sp) { /* no-op */ } private readonly List _plugins = new(); + /// + /// Tracks running loader processes keyed by plugin ID. + /// Used to kill processes on stop and prevent orphan processes. + /// + private readonly ConcurrentDictionary _pluginProcesses = new(); + + /// + /// TaskCompletionSource instances used to await plugin registration after starting a loader process. + /// Key: plugin name (matching PluginInfo.Name). + /// + private readonly ConcurrentDictionary> _registrationTcs = new(); + + /// + /// Default timeout for waiting a plugin to connect and register after process start. + /// + private static readonly TimeSpan DefaultStartTimeout = TimeSpan.FromSeconds(30); + + /// + /// Default timeout for waiting a plugin to gracefully disconnect after sending stop command. + /// + private static readonly TimeSpan DefaultStopTimeout = TimeSpan.FromSeconds(10); + + /// + /// Default timeout for waiting a plugin function call response. + /// + private static readonly TimeSpan DefaultFunctionCallTimeout = TimeSpan.FromSeconds(30); + /// /// Event raised when plugin status changes /// @@ -469,6 +504,12 @@ public async Task RemovePluginAsync(Guid pluginId) return await System.Threading.Tasks.Task.FromResult(false); } + // Stop the plugin if it's running before removing + if (plugin.IsRunning) + { + await StopPluginAsync(pluginId); + } + // Remove from list _plugins.Remove(plugin); @@ -506,7 +547,8 @@ public async Task RemovePluginAsync(Guid pluginId) } /// - /// Starts a plugin + /// Starts a plugin by launching its loader process and waiting for it to register + /// via the PluginsServer WebSocket connection. /// /// The plugin ID /// True if start was successful @@ -521,36 +563,243 @@ public async Task StartPluginAsync(Guid pluginId) if (plugin == null) { Log.Warning($"Plugin not found: {pluginId}"); - return await System.Threading.Tasks.Task.FromResult(false); + return false; + } + + if (plugin.IsRunning) + { + Log.Warning("[PluginsManager] Plugin '{PluginName}' is already running, skipping start", + plugin.PluginInfo?.Name); + return true; } - // TODO: Implement actual plugin startup logic - // This would involve starting the loader process + var loaderInfo = plugin.LoaderInfo; + if (loaderInfo == null) + { + Log.Error("[PluginsManager] Cannot start plugin '{PluginName}': LoaderInfo is null", + plugin.PluginInfo?.Name); + return false; + } - plugin.IsRunning = true; + // Self-loading plugins don't need a separate loader process — they connect on their own. + // Just mark as running; the plugin is responsible for connecting to PluginsServer. + if (loaderInfo.SelfLoad) + { + Log.Information("[PluginsManager] Plugin '{PluginName}' is self-loading, marking as started " + + "(plugin should connect to PluginsServer on its own)", + plugin.PluginInfo?.Name); + plugin.IsRunning = true; + PluginStatusChanged?.Invoke(this, new PluginStatusChangedEventArgs + { + PluginId = pluginId, + PluginName = plugin.PluginInfo?.Name ?? "Unknown", + OldStatus = PluginStatus.Installed, + NewStatus = PluginStatus.Running + }); + return true; + } - Log.Information($"Started plugin: {plugin.PluginInfo?.Name}"); + // Resolve the PluginsServer to get the port for the --connect argument + var pluginsServer = ResolvePluginsServer(); + if (pluginsServer == null) + { + Log.Error("[PluginsManager] Cannot start plugin '{PluginName}': PluginsServer not available", + plugin.PluginInfo?.Name); + return false; + } - // Raise plugin status changed event - PluginStatusChanged?.Invoke(this, new PluginStatusChangedEventArgs + var serverPort = pluginsServer.Port; + if (serverPort == null || serverPort <= 0) { - PluginId = pluginId, - PluginName = plugin.PluginInfo?.Name ?? "Unknown", - OldStatus = PluginStatus.Installed, - NewStatus = PluginStatus.Running - }); + Log.Error("[PluginsManager] Cannot start plugin '{PluginName}': PluginsServer port not assigned", + plugin.PluginInfo?.Name); + return false; + } - return await System.Threading.Tasks.Task.FromResult(true); + // Resolve the loader executable path + var loaderExePath = ResolveLoaderExecutablePath(loaderInfo); + if (loaderExePath == null) + { + Log.Error("[PluginsManager] Cannot start plugin '{PluginName}': " + + "loader executable not found for LoaderName='{LoaderName}', LoaderFramework='{LoaderFramework}'", + plugin.PluginInfo?.Name, loaderInfo.LoaderName, loaderInfo.LoaderFramework); + return false; + } + + // Build the plugin root startup file path + var pluginRootFile = !string.IsNullOrEmpty(plugin.PluginInfo?.RootStartupFileName) + ? Path.Combine(plugin.InstallPath!, plugin.PluginInfo!.RootStartupFileName) + : ""; + + // Build command-line arguments: --load --connect : + var startArgs = BuildStartArguments(pluginRootFile, serverPort.Value); + + Log.Information("[PluginsManager] Starting plugin '{PluginName}' with loader: {LoaderExe} {Args}", + plugin.PluginInfo?.Name, loaderExePath, startArgs); + + // Create a TaskCompletionSource to wait for the plugin to register via WebSocket + var pluginName = plugin.PluginInfo!.Name; + var registrationTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _registrationTcs[pluginName] = registrationTcs; + + // Subscribe to the PluginRegistered event via EventService (the canonical event bus). + // PluginsServer publishes PluginRegistered through EventService, not through its own C# event. + var eventService = ResolveEventService(); + EventHandler? registrationHandler = null; + registrationHandler = (sender, args) => + { + if (args.PluginInfo?.Name == pluginName) + { + Log.Information("[PluginsManager] Plugin '{PluginName}' registered via WebSocket, " + + "completing start operation", pluginName); + registrationTcs.TrySetResult(true); + } + }; + + if (eventService != null) + { + eventService.Subscribe(EventNames.PluginRegistered, registrationHandler); + } + else + { + Log.Warning("[PluginsManager] EventService not available, falling back to polling for registration"); + } + + try + { + // Determine the actual process filename and arguments. + // For .NET DLLs, use 'dotnet '. + // For Python scripts, use 'python '. + // For native executables, use the path directly. + var (processFileName, processArgs) = BuildProcessStartInfo( + loaderExePath, startArgs, loaderInfo); + + // Launch the loader process + var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = processFileName, + Arguments = processArgs, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + WorkingDirectory = plugin.InstallPath! + } + }; + + process.EnableRaisingEvents = true; + + // Log process output for debugging + process.OutputDataReceived += (_, e) => + { + if (!string.IsNullOrEmpty(e.Data)) + Log.Debug("[PluginLoader:{PluginName}] {Output}", pluginName, e.Data); + }; + process.ErrorDataReceived += (_, e) => + { + if (!string.IsNullOrEmpty(e.Data)) + Log.Warning("[PluginLoader:{PluginName}] {Error}", pluginName, e.Data); + }; + + if (!process.Start()) + { + Log.Error("[PluginsManager] Failed to start loader process for plugin '{PluginName}'", + pluginName); + CleanupRegistration(pluginName, registrationHandler, eventService); + return false; + } + + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + // Track the process + _pluginProcesses[pluginId] = process; + + Log.Information("[PluginsManager] Loader process started for plugin '{PluginName}', " + + "PID={ProcessId}, waiting for WebSocket registration (timeout={Timeout}s)", + pluginName, process.Id, DefaultStartTimeout.TotalSeconds); + + // Wait for the plugin to register via WebSocket, with timeout. + // If EventService is available, we wait on the TCS; otherwise we poll. + var registered = false; + + if (eventService != null) + { + // Event-driven wait + using var cts = new CancellationTokenSource(DefaultStartTimeout); + using var ctsRegistration = cts.Token.Register(() => registrationTcs.TrySetCanceled()); + + try + { + registered = await registrationTcs.Task; + } + catch (OperationCanceledException) + { + registered = false; + } + } + else + { + // Polling fallback: check PluginsServer connections periodically + var startTime = DateTime.UtcNow; + while (DateTime.UtcNow - startTime < DefaultStartTimeout) + { + var connection = pluginsServer.Connections + .FirstOrDefault(c => c.PluginInfo?.Name == pluginName); + if (connection != null) + { + registered = true; + break; + } + await Task.Delay(500); + } + } + + if (registered) + { + Log.Information("[PluginsManager] Plugin '{PluginName}' started successfully", pluginName); + // Note: IsRunning is set by OnPluginStatusChanged when the registration event fires, + // but we set it here as well to ensure consistency. + plugin.IsRunning = true; + return true; + } + else + { + // Timeout — roll back + Log.Warning("[PluginsManager] Plugin '{PluginName}' did not register within {Timeout}s, " + + "rolling back (killing loader process)", pluginName, DefaultStartTimeout.TotalSeconds); + + KillPluginProcess(pluginId); + plugin.IsRunning = false; + + PluginStatusChanged?.Invoke(this, new PluginStatusChangedEventArgs + { + PluginId = pluginId, + PluginName = pluginName, + OldStatus = PluginStatus.Installed, + NewStatus = PluginStatus.Error + }); + + return false; + } + } + finally + { + CleanupRegistration(pluginName, registrationHandler, eventService); + } } catch (Exception ex) { - Log.Error(ex, $"In {location}: Error starting plugin {pluginId}: {ex.Message}"); - return await System.Threading.Tasks.Task.FromResult(false); + Log.Error(ex, "[PluginsManager] {Location}: Error starting plugin {PluginId}", location, pluginId); + return false; } } /// - /// Stops a plugin + /// Stops a plugin by sending a stop command via WebSocket and waiting for + /// graceful disconnection. Falls back to killing the loader process on timeout. /// /// The plugin ID /// True if stop was successful @@ -565,31 +814,105 @@ public async Task StopPluginAsync(Guid pluginId) if (plugin == null) { Log.Warning($"Plugin not found: {pluginId}"); - return await System.Threading.Tasks.Task.FromResult(false); + return false; + } + + if (!plugin.IsRunning) + { + Log.Warning("[PluginsManager] Plugin '{PluginName}' is not running, skipping stop", + plugin.PluginInfo?.Name); + return true; + } + + var pluginName = plugin.PluginInfo?.Name ?? "Unknown"; + var pluginsServer = ResolvePluginsServer(); + + // Try graceful shutdown via WebSocket command + var gracefulStopSucceeded = false; + + if (pluginsServer != null) + { + var connection = FindConnectionByPluginName(pluginsServer, pluginName); + if (connection != null) + { + Log.Information("[PluginsManager] Sending stop command to plugin '{PluginName}' " + + "via WebSocket (ConnectionId={ConnectionId})", + pluginName, connection.ConnectionId); + + // Build and send a stop command + try + { + var stopCommand = new Command + { + Request = CommandRequestInfo.ReceiveCommand, + Tags = new Dictionary + { + ["Action"] = "Stop" + } + }; + + var request = new Request + { + Type = RequestTypes.Command, + Version = RequestVersions.V1, + Content = JsonSerializer.Serialize(stopCommand) + }; + + connection.Send(JsonSerializer.Serialize(request)); + gracefulStopSucceeded = true; + } + catch (Exception ex) + { + Log.Warning(ex, "[PluginsManager] Failed to send stop command to plugin '{PluginName}'", + pluginName); + } + } + else + { + Log.Warning("[PluginsManager] No WebSocket connection found for plugin '{PluginName}', " + + "will kill loader process directly", pluginName); + } + } + + // If we sent a stop command, wait for the plugin to disconnect gracefully + if (gracefulStopSucceeded && pluginsServer != null) + { + var disconnected = await WaitForPluginDisconnection(pluginsServer, pluginName, DefaultStopTimeout); + + if (disconnected) + { + Log.Information("[PluginsManager] Plugin '{PluginName}' disconnected gracefully", pluginName); + } + else + { + Log.Warning("[PluginsManager] Plugin '{PluginName}' did not disconnect within {Timeout}s, " + + "forcing process termination", pluginName, DefaultStopTimeout.TotalSeconds); + } } - // TODO: Implement actual plugin shutdown logic - // This would involve stopping the loader process + // Kill the loader process if still running + KillPluginProcess(pluginId); + // Ensure IsRunning is reset plugin.IsRunning = false; - Log.Information($"Stopped plugin: {plugin.PluginInfo?.Name}"); + Log.Information("[PluginsManager] Stopped plugin: {PluginName}", pluginName); // Raise plugin status changed event PluginStatusChanged?.Invoke(this, new PluginStatusChangedEventArgs { PluginId = pluginId, - PluginName = plugin.PluginInfo?.Name ?? "Unknown", + PluginName = pluginName, OldStatus = PluginStatus.Running, NewStatus = PluginStatus.Stopped }); - return await System.Threading.Tasks.Task.FromResult(true); + return true; } catch (Exception ex) { - Log.Error(ex, $"In {location}: Error stopping plugin {pluginId}: {ex.Message}"); - return await System.Threading.Tasks.Task.FromResult(false); + Log.Error(ex, "[PluginsManager] {Location}: Error stopping plugin {PluginId}", location, pluginId); + return false; } } @@ -635,12 +958,14 @@ public void OnPluginStatusChanged(string pluginName, PluginStatus newStatus) } /// - /// Calls a plugin function + /// Calls a plugin function by sending a command via the PluginsServer WebSocket + /// connection and awaiting the plugin's response. Reuses the same request/response + /// correlation mechanism as . /// /// The plugin ID /// The function name - /// Optional parameters - /// The function result + /// Optional parameters (key-value pairs) + /// The function result, or null if the call failed or timed out public async Task CallPluginFunctionAsync(Guid pluginId, string functionName, Dictionary? parameters = null) { const string location = $"{nameof(PluginsManager)}.{nameof(CallPluginFunctionAsync)}"; @@ -651,27 +976,499 @@ public void OnPluginStatusChanged(string pluginName, PluginStatus newStatus) if (plugin == null) { - Log.Warning($"Plugin not found: {pluginId}"); - return await System.Threading.Tasks.Task.FromResult(null); + Log.Warning("[PluginsManager] Plugin not found: {PluginId}", pluginId); + return null; } if (!plugin.IsRunning) { - Log.Warning($"Plugin is not running: {plugin.PluginInfo?.Name}"); - return await System.Threading.Tasks.Task.FromResult(null); + Log.Warning("[PluginsManager] Plugin is not running: {PluginName}", plugin.PluginInfo?.Name); + return null; + } + + var pluginName = plugin.PluginInfo?.Name; + if (string.IsNullOrEmpty(pluginName)) + { + Log.Warning("[PluginsManager] Plugin name is null for plugin {PluginId}", pluginId); + return null; + } + + // Resolve PluginsServer and find the connection for this plugin + var pluginsServer = ResolvePluginsServer(); + if (pluginsServer == null) + { + Log.Error("[PluginsManager] Cannot call function: PluginsServer not available"); + return null; + } + + var connection = FindConnectionByPluginName(pluginsServer, pluginName); + if (connection == null) + { + Log.Warning("[PluginsManager] No WebSocket connection found for plugin '{PluginName}'", pluginName); + return null; + } + + // Convert parameters dictionary to Parameter list + var functionArgs = new List(); + if (parameters != null) + { + foreach (var kvp in parameters) + { + functionArgs.Add(new Parameter + { + Name = kvp.Key, + Type = kvp.Value?.GetType().Name ?? "Object", + Value = kvp.Value?.ToString() ?? string.Empty, + IsOptional = false + }); + } + } + + // Generate RequestId for correlating the async response + var requestId = Guid.NewGuid().ToString(); + + // Build the Command with function call details + var command = new Command + { + SendTime = DateTime.UtcNow, + Request = CommandRequestInfo.ReceiveCommand, + PluginConnectionId = connection.ConnectionId ?? string.Empty, + FunctionName = functionName, + FunctionArgs = functionArgs, + Tags = new Dictionary + { + ["RequestId"] = requestId + } + }; + + // Build the Request wrapping the Command + var request = new Request + { + Type = RequestTypes.Command, + Version = RequestVersions.V1, + Content = JsonSerializer.Serialize(command) + }; + + // Set up TaskCompletionSource to await the plugin response + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + // Subscribe to PluginResponse via EventService (the canonical event bus) + var eventService = ResolveEventService(); + if (eventService == null) + { + Log.Error("[PluginsManager] Cannot call function: EventService not available"); + return null; + } + + EventHandler? responseHandler = null; + responseHandler = (sender, args) => + { + if (args.RequestId == requestId) + { + tcs.TrySetResult(args.Content); + } + }; + eventService.Subscribe(EventNames.PluginResponse, responseHandler); + + try + { + // Send the request to the plugin via WebSocket + var requestJson = JsonSerializer.Serialize(request); + connection.Send(requestJson); + + Log.Information("[PluginsManager] Sent function call '{FunctionName}' to plugin '{PluginName}', " + + "RequestId: {RequestId}", functionName, pluginName, requestId); + + // Wait for response with timeout + using var cts = new CancellationTokenSource(DefaultFunctionCallTimeout); + using var ctsRegistration = cts.Token.Register(() => tcs.TrySetCanceled()); + + var responseContent = await tcs.Task; + + // Deserialize the response Command to extract the return value + try + { + var responseCommand = JsonSerializer.Deserialize(responseContent); + + // The response body contains the function result + if (responseCommand.Body != null && responseCommand.BodyLength > 0) + { + var resultJson = Encoding.UTF8.GetString( + responseCommand.Body.AsSpan(0, responseCommand.BodyLength).ToArray()); + var result = JsonSerializer.Deserialize(resultJson); + Log.Information("[PluginsManager] Function '{FunctionName}' on plugin '{PluginName}' " + + "returned result", functionName, pluginName); + return result; + } + + // Fallback: try to extract result from Tags + if (responseCommand.Tags != null && + responseCommand.Tags.TryGetValue("Result", out var resultValue)) + { + Log.Information("[PluginsManager] Function '{FunctionName}' on plugin '{PluginName}' " + + "returned result from Tags", functionName, pluginName); + return resultValue; + } + } + catch (Exception ex) + { + Log.Warning(ex, "[PluginsManager] Failed to deserialize function response, " + + "returning raw content"); + return responseContent; + } + + Log.Information("[PluginsManager] Function '{FunctionName}' on plugin '{PluginName}' " + + "completed with no return value", functionName, pluginName); + return null; + } + catch (OperationCanceledException) + { + Log.Warning("[PluginsManager] Function call '{FunctionName}' on plugin '{PluginName}' " + + "timed out after {Timeout}s", functionName, pluginName, + DefaultFunctionCallTimeout.TotalSeconds); + return null; + } + finally + { + eventService.Unsubscribe( + EventNames.PluginResponse, responseHandler); + } + } + catch (Exception ex) + { + Log.Error(ex, "[PluginsManager] {Location}: Error calling function {FunctionName} " + + "on plugin {PluginId}", location, functionName, pluginId); + return null; + } + } + + // ──────────────────────────── Private Helpers ──────────────────────────── + + /// + /// Resolves the PluginsServer instance from the DI container. + /// Returns null if ServiceHost is not initialized or the server is not available. + /// + private PluginsServer? ResolvePluginsServer() + { + try + { + if (!ServiceHost.IsInitialized) + { + Log.Warning("[PluginsManager] ServiceHost not initialized, cannot resolve PluginsServer"); + return null; + } + + var server = ServiceHost.GetRequiredService() as PluginsServer; + if (server == null) + Log.Warning("[PluginsManager] IPluginServer is not a PluginsServer instance"); + + return server; + } + catch (Exception ex) + { + Log.Warning(ex, "[PluginsManager] Failed to resolve PluginsServer from ServiceHost"); + return null; + } + } + + /// + /// Resolves the EventService instance from the DI container. + /// Returns null if ServiceHost is not initialized or the service is not available. + /// + private EventService? ResolveEventService() + { + try + { + if (!ServiceHost.IsInitialized) + return null; + + return ServiceHost.GetRequiredService() as EventService; + } + catch (Exception ex) + { + Log.Debug(ex, "[PluginsManager] Failed to resolve EventService from ServiceHost"); + return null; + } + } + + /// + /// Resolves the loader executable path based on LoaderInfo metadata. + /// Searches in the configured loaders install path (e.g., ./Loaders/) for a matching + /// loader directory named after LoaderName, then looks for the executable. + /// For .NET loaders, returns the DLL path (the process will be launched via 'dotnet'). + /// + private string? ResolveLoaderExecutablePath(LoaderInfo loaderInfo) + { + try + { + // Read the loaders install path from configuration + var loadersInstallPath = GetLoadersInstallPath(); + if (string.IsNullOrEmpty(loadersInstallPath)) + { + Log.Warning("[PluginsManager] Loaders install path is not configured"); + return null; + } + + var fullPath = Path.GetFullPath(loadersInstallPath); + if (!Directory.Exists(fullPath)) + { + Log.Warning("[PluginsManager] Loaders directory does not exist: {Path}", fullPath); + return null; + } + + // Look for a subdirectory matching the LoaderName + var loaderDir = Path.Combine(fullPath, loaderInfo.LoaderName); + if (!Directory.Exists(loaderDir)) + { + // Fallback: search all subdirectories for a loader matching LoaderName + var matchingDir = Directory.GetDirectories(fullPath) + .FirstOrDefault(d => + { + var dirName = Path.GetFileName(d); + return dirName.Equals(loaderInfo.LoaderName, StringComparison.OrdinalIgnoreCase) + || dirName.StartsWith(loaderInfo.LoaderName, StringComparison.OrdinalIgnoreCase); + }); + + if (matchingDir != null) + loaderDir = matchingDir; + else + { + Log.Warning("[PluginsManager] Loader directory not found for LoaderName='{LoaderName}' in {Path}", + loaderInfo.LoaderName, fullPath); + return null; + } + } + + // Determine the executable name based on LoaderFramework + var exeName = DetermineLoaderExecutableName(loaderInfo); + var exePath = Path.Combine(loaderDir, exeName); + + if (File.Exists(exePath)) + { + Log.Information("[PluginsManager] Found loader executable: {ExePath}", exePath); + return exePath; + } + + // For .NET loaders, the executable might be under a publish directory + var publishDir = Path.Combine(loaderDir, "publish"); + if (Directory.Exists(publishDir)) + { + exePath = Path.Combine(publishDir, exeName); + if (File.Exists(exePath)) + { + Log.Information("[PluginsManager] Found loader executable in publish dir: {ExePath}", exePath); + return exePath; + } + } + + // Try with .dll extension for dotnet execution + if (loaderInfo.LoaderFramework.Equals(".NET", StringComparison.OrdinalIgnoreCase) + || loaderInfo.LoaderLanguage.Equals("C#", StringComparison.OrdinalIgnoreCase)) + { + var dllName = Path.GetFileNameWithoutExtension(exeName) + ".dll"; + var dllPath = Path.Combine(loaderDir, dllName); + if (File.Exists(dllPath)) + { + Log.Information("[PluginsManager] Found loader DLL for dotnet execution: {DllPath}", dllPath); + return dllPath; + } + + if (Directory.Exists(publishDir)) + { + dllPath = Path.Combine(publishDir, dllName); + if (File.Exists(dllPath)) + { + Log.Information("[PluginsManager] Found loader DLL in publish dir: {DllPath}", dllPath); + return dllPath; + } + } + } + + Log.Warning("[PluginsManager] Loader executable not found: {ExeName} in {LoaderDir}", exeName, loaderDir); + return null; + } + catch (Exception ex) + { + Log.Error(ex, "[PluginsManager] Error resolving loader executable path"); + return null; + } + } + + /// + /// Determines the loader executable file name based on the LoaderFramework and LoaderLanguage. + /// + private static string DetermineLoaderExecutableName(LoaderInfo loaderInfo) + { + // For .NET/C# loaders, use dotnet to run the DLL + if (loaderInfo.LoaderFramework.Equals(".NET", StringComparison.OrdinalIgnoreCase) + || loaderInfo.LoaderLanguage.Equals("C#", StringComparison.OrdinalIgnoreCase)) + { + // The actual file might be a DLL, but we return the expected name; + // ResolveLoaderExecutablePath will handle the dotnet vs direct execution. + return $"{loaderInfo.LoaderName}.dll"; + } + + // For Python loaders + if (loaderInfo.LoaderLanguage.Equals("Python", StringComparison.OrdinalIgnoreCase)) + { + return "main.py"; + } + + // Default: assume the loader name is the executable name + return $"{loaderInfo.LoaderName}.exe"; + } + + /// + /// Gets the loaders install path from configuration, falling back to the default "./Loaders/". + /// + private static string GetLoadersInstallPath() + { + try + { + if (ServiceHost.IsInitialized) + { + var configService = ServiceHost.GetRequiredService(); + var loadersConf = configService.AppConfig?.Loaders; + if (loadersConf != null && !string.IsNullOrEmpty(loadersConf.InstallPath)) + return loadersConf.InstallPath; } + } + catch (Exception ex) + { + Log.Debug(ex, "[PluginsManager] Could not read loaders install path from config, using default"); + } - // TODO: Implement actual plugin function call - // This would involve sending a request to the loader process + return "./Loaders/"; + } - Log.Information($"Called function {functionName} on plugin {plugin.PluginInfo?.Name}"); + /// + /// Builds the command-line arguments for starting a loader process. + /// Format: --load "" --connect 127.0.0.1: + /// + private static string BuildStartArguments(string pluginRootFile, int serverPort) + { + var sb = new StringBuilder(); + if (!string.IsNullOrEmpty(pluginRootFile)) + sb.Append($"--load \"{pluginRootFile}\" "); + sb.Append($"--connect 127.0.0.1:{serverPort}"); + return sb.ToString(); + } - return await System.Threading.Tasks.Task.FromResult(null); + /// + /// Builds the process start info (FileName, Arguments) based on the loader type. + /// For .NET DLLs: uses 'dotnet' as FileName with the DLL path as the first argument. + /// For Python scripts: uses 'python' as FileName with the script path as the first argument. + /// For native executables: uses the path directly as FileName. + /// + private static (string fileName, string arguments) BuildProcessStartInfo( + string loaderExePath, string startArgs, LoaderInfo loaderInfo) + { + var isDotNet = loaderInfo.LoaderFramework.Equals(".NET", StringComparison.OrdinalIgnoreCase) + || loaderInfo.LoaderLanguage.Equals("C#", StringComparison.OrdinalIgnoreCase); + var isPython = loaderInfo.LoaderLanguage.Equals("Python", StringComparison.OrdinalIgnoreCase); + + if (isDotNet && loaderExePath.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) + { + // dotnet + return ("dotnet", $"\"{loaderExePath}\" {startArgs}"); + } + + if (isPython && loaderExePath.EndsWith(".py", StringComparison.OrdinalIgnoreCase)) + { + // python + return ("python", $"\"{loaderExePath}\" {startArgs}"); + } + + // Native executable: run directly + return (loaderExePath, startArgs); + } + + /// + /// Finds a WebSocket connection by plugin name from the PluginsServer. + /// + private static IPluginConnection? FindConnectionByPluginName(PluginsServer pluginsServer, string pluginName) + { + try + { + return pluginsServer.Connections + .FirstOrDefault(c => c.PluginInfo?.Name == pluginName) as IPluginConnection; } catch (Exception ex) { - Log.Error(ex, $"In {location}: Error calling function {functionName} on plugin {pluginId}: {ex.Message}"); - return await System.Threading.Tasks.Task.FromResult(null); + Log.Warning(ex, "[PluginsManager] Error finding connection for plugin '{PluginName}'", pluginName); + return null; + } + } + + /// + /// Waits for a plugin to disconnect from the PluginsServer within the specified timeout. + /// Polls the connection list to detect disconnection. + /// + private static async Task WaitForPluginDisconnection(PluginsServer pluginsServer, string pluginName, TimeSpan timeout) + { + var startTime = DateTime.UtcNow; + while (DateTime.UtcNow - startTime < timeout) + { + var connection = pluginsServer.Connections + .FirstOrDefault(c => c.PluginInfo?.Name == pluginName); + + if (connection == null) + return true; // Plugin has disconnected + + await Task.Delay(200); + } + + return false; // Timeout — plugin still connected + } + + /// + /// Kills the loader process for a plugin (if tracked) and removes it from the process dictionary. + /// + private void KillPluginProcess(Guid pluginId) + { + if (_pluginProcesses.TryRemove(pluginId, out var process)) + { + try + { + if (!process.HasExited) + { + Log.Information("[PluginsManager] Killing loader process PID={ProcessId} for plugin {PluginId}", + process.Id, pluginId); + process.Kill(entireProcessTree: true); + } + } + catch (InvalidOperationException ex) + { + Log.Debug(ex, "[PluginsManager] Process already exited for plugin {PluginId}", pluginId); + } + catch (Exception ex) + { + Log.Warning(ex, "[PluginsManager] Error killing loader process for plugin {PluginId}", pluginId); + } + finally + { + try { process.Dispose(); } catch { } + } + } + } + + /// + /// Cleans up the registration TaskCompletionSource and unsubscribes the event handler from EventService. + /// + private void CleanupRegistration(string pluginName, EventHandler handler, EventService? eventService) + { + _registrationTcs.TryRemove(pluginName, out _); + + if (eventService != null) + { + try + { + eventService.Unsubscribe(EventNames.PluginRegistered, handler); + } + catch (Exception ex) + { + Log.Debug(ex, "[PluginsManager] Error unsubscribing from PluginRegistered event"); + } } } -} +} \ No newline at end of file diff --git a/KitX Clients/KitX Core/KitX.Core/Security/SecurityManager.cs b/KitX Clients/KitX Core/KitX.Core/Security/SecurityManager.cs index ddd38446..f049e1ca 100644 --- a/KitX Clients/KitX Core/KitX.Core/Security/SecurityManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/Security/SecurityManager.cs @@ -1,8 +1,9 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Security.Cryptography; using System.Text; +using System.Text.Json; using KitX.Core.Contract.Security; using KitX.Core.Configuration; using KitX.Core.Device; @@ -316,11 +317,12 @@ private static bool IsSameDevice(string mac1, string mac2) } /// - /// Encrypts a string + /// Encrypts a string. Uses RSA-only for short content (< 90 chars, backward compatible), + /// and RSA+AES hybrid encryption for long content. /// /// The content to encrypt /// The target device MAC address - /// The encrypted string + /// The encrypted string (Base64). First byte is a flag: 0=RSA-only, 1=Hybrid. public async Task EncryptStringAsync(string content, string targetDeviceMacAddress) { if (_rsaInstance == null) @@ -328,17 +330,47 @@ public async Task EncryptStringAsync(string content, string targetDevice throw new InvalidOperationException("RSA instance not initialized"); } - if (content.Length >= 90) - { - // TODO: Implement data splitting for longer content - Log.Warning("Data length is too long for RSA encryption"); - } - try { - var dataBytes = Encoding.UTF8.GetBytes(content); - var encrypted = _rsaInstance.Encrypt(dataBytes, RSAEncryptionPadding.OaepSHA256); - return Convert.ToBase64String(encrypted); + if (content.Length < 90) + { + // RSA-only encryption (backward compatible) + var dataBytes = Encoding.UTF8.GetBytes(content); + var encrypted = _rsaInstance.Encrypt(dataBytes, RSAEncryptionPadding.OaepSHA256); + var encryptedBytes = Convert.FromBase64String(Convert.ToBase64String(encrypted)); + // Prepend flag byte 0 (RSA-only) + var result = new byte[1 + encryptedBytes.Length]; + result[0] = 0; + Buffer.BlockCopy(encryptedBytes, 0, result, 1, encryptedBytes.Length); + return Convert.ToBase64String(result); + } + else + { + // Hybrid encryption: RSA + AES + // Find target device key by MAC address + var deviceKeys = GetDeviceKeys(); + var targetKey = deviceKeys.FirstOrDefault(k => IsSameDevice(k.MacAddress, targetDeviceMacAddress)) + ?? throw new InvalidOperationException($"No device key found for target MAC: {targetDeviceMacAddress}"); + + var deviceKey = new DeviceKey + { + Device = new DeviceLocator + { + MacAddress = targetDeviceMacAddress, + DeviceName = targetKey.Device.DeviceName + }, + RsaPublicKeyPem = targetKey.RsaPublicKeyPem, + }; + + var encryptedContent = RsaEncryptContent(deviceKey, content); + var json = System.Text.Json.JsonSerializer.Serialize(encryptedContent); + var jsonBytes = Encoding.UTF8.GetBytes(json); + // Prepend flag byte 1 (Hybrid) + var result = new byte[1 + jsonBytes.Length]; + result[0] = 1; + Buffer.BlockCopy(jsonBytes, 0, result, 1, jsonBytes.Length); + return Convert.ToBase64String(result); + } } catch (Exception ex) { @@ -348,9 +380,10 @@ public async Task EncryptStringAsync(string content, string targetDevice } /// - /// Decrypts a string + /// Decrypts a string. Reads the first byte flag to determine encryption mode: + /// 0=RSA-only, 1=RSA+AES hybrid. /// - /// The encrypted content + /// The encrypted content (Base64) /// The source device MAC address /// The decrypted string public async Task DecryptStringAsync(string encryptedContent, string sourceDeviceMacAddress) @@ -360,17 +393,43 @@ public async Task DecryptStringAsync(string encryptedContent, string sou throw new InvalidOperationException("RSA instance not initialized"); } - if (encryptedContent.Length >= 90) - { - // TODO: Implement data splitting for longer content - Log.Warning("Data length is too long for RSA decryption"); - } - try { - var encryptedDataBytes = Convert.FromBase64String(encryptedContent); - var decrypted = _rsaInstance.Decrypt(encryptedDataBytes, RSAEncryptionPadding.OaepSHA256); - return Encoding.UTF8.GetString(decrypted); + var encryptedBytes = Convert.FromBase64String(encryptedContent); + + if (encryptedBytes.Length == 0) + throw new InvalidOperationException("Encrypted content is empty"); + + // Read the first byte as the encryption mode flag + var mode = encryptedBytes[0]; + + if (mode == 0) + { + // RSA-only decryption + var rsaEncryptedBytes = new byte[encryptedBytes.Length - 1]; + Buffer.BlockCopy(encryptedBytes, 1, rsaEncryptedBytes, 0, rsaEncryptedBytes.Length); + var decrypted = _rsaInstance.Decrypt(rsaEncryptedBytes, RSAEncryptionPadding.OaepSHA256); + return Encoding.UTF8.GetString(decrypted); + } + else if (mode == 1) + { + // Hybrid decryption: RSA + AES + var jsonBytes = new byte[encryptedBytes.Length - 1]; + Buffer.BlockCopy(encryptedBytes, 1, jsonBytes, 0, jsonBytes.Length); + var json = Encoding.UTF8.GetString(jsonBytes); + var encryptedContentObj = System.Text.Json.JsonSerializer.Deserialize(json) + ?? throw new InvalidOperationException("Failed to deserialize encrypted content"); + + // Use local device key (with private key) to decrypt + if (_localDeviceKey == null) + throw new InvalidOperationException("Local device key not initialized"); + + return RsaDecryptContent(_localDeviceKey, encryptedContentObj); + } + else + { + throw new InvalidOperationException($"Unknown encryption mode flag: {mode}"); + } } catch (Exception ex) { diff --git a/KitX Clients/KitX Dashboard b/KitX Clients/KitX Dashboard index 7827baaa..37e430e4 160000 --- a/KitX Clients/KitX Dashboard +++ b/KitX Clients/KitX Dashboard @@ -1 +1 @@ -Subproject commit 7827baaaa72c343d04092c55485d5a1c4d2c4784 +Subproject commit 37e430e4a25209f732a65ddf7b37ff18bae6354f diff --git a/KitX Standard b/KitX Standard index 94953899..6cf48eb5 160000 --- a/KitX Standard +++ b/KitX Standard @@ -1 +1 @@ -Subproject commit 9495389969c49dc9b9ba23e6278abce392ec0931 +Subproject commit 6cf48eb598d68030a9e3013bd37119e9e1a9c6f8 From 876027c6c21464ef0b016aff4d9b7b04d83c19b9 Mon Sep 17 00:00:00 2001 From: StarInk Date: Mon, 27 Apr 2026 21:22:35 +0200 Subject: [PATCH 061/110] =?UTF-8?q?=F0=9F=A7=A9=20Refactor:=20=E7=A7=BB?= =?UTF-8?q?=E9=99=A4=E8=BF=87=E6=97=B6=E7=9A=84ServiceHost=E7=9B=B8?= =?UTF-8?q?=E5=85=B3=E4=BB=A3=E7=A0=81=EF=BC=8C=E7=AE=80=E5=8C=96=E4=BE=9D?= =?UTF-8?q?=E8=B5=96=E6=B3=A8=E5=85=A5=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../KitX.Core/Activity/ActivityManager.cs | 98 +++++++++++++------ .../Announcement/AnnouncementManager.cs | 73 +++++++++++--- .../KitX.Core/Configuration/ConfigManager.cs | 14 +-- .../KitX Core/KitX.Core/ConstantTable.cs | 56 +++++------ .../DI/CoreServiceCollectionExtensions.cs | 54 +--------- .../Device/DevicesDiscoveryServer.cs | 6 -- .../KitX.Core/Device/DevicesOrganizer.cs | 6 -- .../KitX.Core/Device/DevicesServer.cs | 6 -- .../KitX.Core/Device/PluginsServer.cs | 6 -- .../KitX Core/KitX.Core/Event/EventService.cs | 6 -- .../FileWatcher/FileWatcherManager.cs | 6 -- .../KitX.Core/Hotkey/KeyHookManager.cs | 6 -- .../KitX.Core/Plugin/PluginsManager.cs | 24 +---- .../KitX.Core/Security/SecurityManager.cs | 6 -- .../KitX.Core/Statistics/StatisticsManager.cs | 6 -- .../KitX Core/KitX.Core/Tasks/TasksManager.cs | 23 ----- .../KitX.Core/Workflow/TriggerManager.cs | 6 -- .../Workflow/WorkflowScriptService.cs | 6 -- .../Workflow/WorkflowStorageService.cs | 6 -- KitX Clients/KitX Dashboard | 2 +- 20 files changed, 158 insertions(+), 258 deletions(-) diff --git a/KitX Clients/KitX Core/KitX.Core/Activity/ActivityManager.cs b/KitX Clients/KitX Core/KitX.Core/Activity/ActivityManager.cs index e45abcb9..e2f22d38 100644 --- a/KitX Clients/KitX Core/KitX.Core/Activity/ActivityManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/Activity/ActivityManager.cs @@ -9,7 +9,6 @@ using KitX.Core.Event; using LiteDB; using KitX.Core.Tasks; -using KitX.Core.DI; using Serilog; namespace KitX.Core.Activity; @@ -20,28 +19,6 @@ namespace KitX.Core.Activity; /// public class ActivityManager : IActivityService { - /// - /// Gets the singleton instance (resolves from ServiceHost when available). - /// Internal code should use constructor injection instead. - /// - public static ActivityManager Instance - { - get - { - if (ServiceHost.IsInitialized) - return (ActivityManager)ServiceHost.GetRequiredService(); - Log.Error("[ActivityManager] Instance: ServiceHost not initialized! Returning orphan instance — " + - "this indicates a DI initialization order bug. Use ServiceHost/constructor injection instead."); - return new ActivityManager(); - } - } - - /// - /// Kept for backward compatibility — ServiceHost is now the single source of truth. - /// - [Obsolete("ServiceHost is now the single source of truth. This method is a no-op.")] - internal static void SetServiceProvider(IServiceProvider? sp) { /* no-op */ } - private static readonly object _activityRecordLock = new(); private static LiteDatabase? _activitiesDatabase; @@ -182,25 +159,33 @@ public IList GetActivities(DateTime? startDate = null, DateTime? endD { var activities = ReadActivities(); + // Convert to IActivity interface first to get proper Timestamp values + var adaptedActivities = activities.Select(a => new ActivityAdapter(a)).ToList(); + // Filter by date range if specified if (startDate.HasValue || endDate.HasValue) { - activities = activities.Where(a => + adaptedActivities = adaptedActivities.Where(a => { - // Activity doesn't have Timestamp, so we skip date filtering for now - // TODO: Add Timestamp property to Common.Activity.Activity or use alternative filtering + var ts = a.Timestamp; + + if (startDate.HasValue && ts < startDate.Value) + return false; + + if (endDate.HasValue && ts > endDate.Value) + return false; + return true; }).ToList(); } // Apply limit - if (limit > 0 && activities.Count > limit) + if (limit > 0 && adaptedActivities.Count > limit) { - activities = activities.Take(limit).ToList(); + adaptedActivities = adaptedActivities.Take(limit).ToList(); } - // Convert to IActivity interface - return activities.Select(a => new ActivityAdapter(a)).ToList(); + return adaptedActivities.Cast().ToList(); } /// @@ -283,9 +268,30 @@ private class ActivityAdapter : IActivity { private readonly CActivity _activity; + private readonly DateTime _timestamp; + public ActivityAdapter(CActivity activity) { _activity = activity; + + // Extract the earliest ExecuteTime from OpenAndCloseOperations as the timestamp. + // If no operations exist, fall back to decoding the timestamp from the Id, + // which is derived from DateTime.UtcNow.Ticks.GetHashCode(). + var openCloseOps = activity.Operations?.OpenAndCloseOperations; + + if (openCloseOps is { Count: > 0 }) + { + var earliest = openCloseOps + .Where(op => op.ExecuteTime.HasValue) + .MinBy(op => op.ExecuteTime); + + _timestamp = earliest?.ExecuteTime + ?? DecodeTimestampFromId(activity.Id); + } + else + { + _timestamp = DecodeTimestampFromId(activity.Id); + } } public CActivity Activity => _activity; @@ -294,7 +300,7 @@ public ActivityAdapter(CActivity activity) public string Type => _activity.Name ?? "Unknown"; - public DateTime Timestamp => DateTime.UtcNow; // Activity doesn't have Timestamp, use current time + public DateTime Timestamp => _timestamp; public Dictionary Details => new() { @@ -303,6 +309,36 @@ public ActivityAdapter(CActivity activity) { "Author", _activity.Author ?? "" }, { "Status", _activity.Status.ToString() } }; + + /// + /// Decodes a timestamp from the activity Id, which is generated from + /// DateTime.UtcNow.Ticks.GetHashCode(). Since GetHashCode() is lossy, + /// this provides a best-effort approximation by reversing the hash operation + /// using the lower 32 bits of the ticks. + /// + private static DateTime DecodeTimestampFromId(int id) + { + // Id is derived from DateTime.UtcNow.Ticks.GetHashCode(). + // GetHashCode() for Int64 returns (int)(value ^ (value >> 32)). + // We can recover the lower 32 bits by reversing the XOR: + // lower32 = (int)(ticks ^ (ticks >> 32)) + // Since we only have the hash result, we reconstruct the approximate ticks + // by using the current UTC ticks as a reference for the upper 32 bits. + var nowTicks = DateTime.UtcNow.Ticks; + var upper32 = (int)(nowTicks >> 32); + var lower32 = (int)((uint)id ^ (uint)(upper32 ^ (int)(nowTicks >> 32))); + + // Combine upper and lower 32 bits to form the approximate ticks + var approxTicks = ((long)upper32 << 32) | (uint)lower32; + + // Clamp to valid DateTime range + if (approxTicks < DateTime.MinValue.Ticks) + approxTicks = DateTime.MinValue.Ticks; + else if (approxTicks > DateTime.MaxValue.Ticks) + approxTicks = DateTime.MaxValue.Ticks; + + return new DateTime(approxTicks, DateTimeKind.Utc); + } } /// diff --git a/KitX Clients/KitX Core/KitX.Core/Announcement/AnnouncementManager.cs b/KitX Clients/KitX Core/KitX.Core/Announcement/AnnouncementManager.cs index 86bfc40c..e27c26bc 100644 --- a/KitX Clients/KitX Core/KitX.Core/Announcement/AnnouncementManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/Announcement/AnnouncementManager.cs @@ -1,16 +1,16 @@ -using KitX.Core.Configuration; +using KitX.Core.Configuration; using KitX.Core.Contract.Announcement; using KitX.Core.Contract.Configuration; using Microsoft.AspNetCore.Components; using Serilog; using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Net.Http; using System.Text; using System.Text.Json; using System.Threading.Tasks; -using Microsoft.Extensions.DependencyInjection; namespace KitX.Core.Announcement; @@ -36,12 +36,6 @@ public static AnnouncementManager Instance } } - /// - /// Kept for backward compatibility — ServiceHost is now the single source of truth. - /// - [Obsolete("ServiceHost is now the single source of truth. This method is a no-op.")] - internal static void SetServiceProvider(IServiceProvider? sp) { /* no-op */ } - private readonly HashSet _acceptedAnnouncementIds = new(); private readonly JsonSerializerOptions _serializerOptions = new() { @@ -51,6 +45,8 @@ internal static void SetServiceProvider(IServiceProvider? sp) { /* no-op */ } private readonly IConfigService? _configService; + private static readonly string AcceptedAnnouncementsFileName = "accepted_announcements.json"; + /// /// Gets the announcement configuration /// @@ -153,7 +149,7 @@ public async Task> CheckNewAnnouncementsAsync() PublishDate = item, Content = markdown, Title = $"Announcement - {item:yyyy-MM-dd}", - Version = "1.0" // TODO: Get from API if available + Version = "1.0" // TODO: (Low Priority) Get version from API response when API supports it }); } } @@ -208,22 +204,67 @@ public void SaveAnnouncementConfig() } /// - /// Loads accepted announcement IDs from storage - /// TODO: Implement persistence (file or database) + /// Loads accepted announcement IDs from persistent storage /// private void LoadAcceptedIds() { - // TODO: Load from config file - // For now, initialize as empty + try + { + var path = Path.GetFullPath(Path.Combine(ConstantTable.DataPath, AcceptedAnnouncementsFileName)); + + if (File.Exists(path)) + { + var json = File.ReadAllText(path); + var ids = JsonSerializer.Deserialize>(json, _serializerOptions); + + if (ids != null) + { + _acceptedAnnouncementIds.Clear(); + foreach (var id in ids) + { + _acceptedAnnouncementIds.Add(id); + } + + Log.Debug("[AnnouncementManager] Loaded {Count} accepted announcement IDs from {Path}", + _acceptedAnnouncementIds.Count, path); + } + } + else + { + Log.Debug("[AnnouncementManager] No accepted announcements file found at {Path}, starting fresh", path); + } + } + catch (Exception ex) + { + Log.Error(ex, "[AnnouncementManager] Failed to load accepted announcement IDs"); + } } /// - /// Saves accepted announcement IDs to storage - /// TODO: Implement persistence (file or database) + /// Saves accepted announcement IDs to persistent storage /// private void SaveAcceptedIds() { - // TODO: Save to config file + try + { + var path = Path.GetFullPath(Path.Combine(ConstantTable.DataPath, AcceptedAnnouncementsFileName)); + var directory = Path.GetDirectoryName(path); + + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + + var json = JsonSerializer.Serialize(_acceptedAnnouncementIds, _serializerOptions); + File.WriteAllText(path, json); + + Log.Debug("[AnnouncementManager] Saved {Count} accepted announcement IDs to {Path}", + _acceptedAnnouncementIds.Count, path); + } + catch (Exception ex) + { + Log.Error(ex, "[AnnouncementManager] Failed to save accepted announcement IDs"); + } } /// diff --git a/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigManager.cs b/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigManager.cs index 2b9caee5..724f2222 100644 --- a/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigManager.cs @@ -1,8 +1,7 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using KitX.Core.Contract.Configuration; -using Microsoft.Extensions.DependencyInjection; using Serilog; namespace KitX.Core.Configuration; @@ -21,17 +20,6 @@ public class ConfigManager : IConfigService, IDisposable /// public static ConfigManager Instance => _instance ??= new ConfigManager(); - /// - /// Kept for backward compatibility — ServiceHost is now the single source of truth. - /// ConfigManager uses its own _instance field for pre-DI initialization. - /// - [Obsolete("ServiceHost is now the single source of truth. This method is a no-op.")] - internal static void SetServiceProvider(IServiceProvider? sp) - { - // ConfigManager uses _instance field for singleton, not _serviceProvider. - // ServiceHost is now the single source of truth for DI resolution. - } - private string? _configLocation; private readonly Dictionary _configs = new(); diff --git a/KitX Clients/KitX Core/KitX.Core/ConstantTable.cs b/KitX Clients/KitX Core/KitX.Core/ConstantTable.cs index 0f9cde1c..b0a4d7ad 100644 --- a/KitX Clients/KitX Core/KitX.Core/ConstantTable.cs +++ b/KitX Clients/KitX Core/KitX.Core/ConstantTable.cs @@ -1,71 +1,71 @@ -using System; +using System; using Common.BasicHelper.Utils.Extensions; namespace KitX.Core; public static class ConstantTable { - internal const string AppName = "KitX"; + public const string AppName = "KitX"; - internal const string AppFullName = "KitX Dashboard"; + public const string AppFullName = "KitX Dashboard"; - internal const string DataPath = "./Data/"; + public const string DataPath = "./Data/"; - internal const string LanguageFilePath = "./Languages/"; + public const string LanguageFilePath = "./Languages/"; - internal const string AssetsPath = "./Assets/"; + public const string AssetsPath = "./Assets/"; - internal const string UpdateSavePath = "./Update/"; + public const string UpdateSavePath = "./Update/"; - internal const string IconBase64FileName = "KitX.Base64.txt"; + public const string IconBase64FileName = "KitX.Base64.txt"; private const string activitiesDataBaseFilePath = $"{DataPath}Activities.db"; private const string thirdPartyLicenseFilePath = $"{AssetsPath}ThirdPartyLicense.md"; - internal static string ActivitiesDataBaseFilePath => activitiesDataBaseFilePath.GetFullPath(); + public static string ActivitiesDataBaseFilePath => activitiesDataBaseFilePath.GetFullPath(); - internal static string ThirdPartyLicenseFilePath => thirdPartyLicenseFilePath.GetFullPath(); + public static string ThirdPartyLicenseFilePath => thirdPartyLicenseFilePath.GetFullPath(); - internal static bool IsExchangingDeviceKey = false; + public static bool IsExchangingDeviceKey = false; - internal static string? ExchangeDeviceKeyCode; + public static string? ExchangeDeviceKeyCode; /// - /// Devices Server Port - public for cross-assembly access + /// Devices Server Port /// public static int DevicesServerPort = -1; /// - /// Plugins Server Port - public for cross-assembly access + /// Plugins Server Port /// public static int PluginsServerPort = -1; - internal static bool Running = true; + public static bool Running = true; - internal static bool Exiting = false; + public static bool Exiting = false; - internal static bool Restarting = false; + public static bool Restarting = false; - internal static bool EnsureExiting = false; + public static bool EnsureExiting = false; - internal static bool IsMainMachine = false; + public static bool IsMainMachine = false; - internal static string? MainMachineAddress; + public static string? MainMachineAddress; - internal static int MainMachinePort = -1; + public static int MainMachinePort = -1; - internal static bool SkipNetworkSystemOnStartup = false; + public static bool SkipNetworkSystemOnStartup = false; - internal static DateTime ServerBuildTime = new(); + public static DateTime ServerBuildTime = new(); - internal const string ApiGetAnnouncements = "get-announcements.php"; + public const string ApiGetAnnouncements = "get-announcements.php"; - internal const string ApiGetAnnouncement = "get-announcement.php"; + public const string ApiGetAnnouncement = "get-announcement.php"; - internal static string KitXIconBase64 = string.Empty; + public static string KitXIconBase64 = string.Empty; - internal static bool IsSingleProcessStartMode = true; + public static bool IsSingleProcessStartMode = true; - internal static bool EnabledConfigFileHotReload = true; + public static bool EnabledConfigFileHotReload = true; } diff --git a/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs b/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs index cb08a5a7..a8f2f78b 100644 --- a/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs +++ b/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs @@ -127,8 +127,7 @@ public static IServiceCollection AddCoreServices(this IServiceCollection service return new RealPluginManager(pluginsServer, provider.GetRequiredService()); }); - // Pre-resolve RealPluginManager to ensure it's initialized before PluginsServer.Run() is called. - // This is done in InitializeCoreServices() after the ServiceProvider is built. + // RealPluginManager is pre-resolved by the caller after BuildServiceProvider(). // Phase 5: Announcement Service Log.Information("Registering IAnnouncementService..."); @@ -222,58 +221,11 @@ public static IServiceCollection AddCoreServices(this IServiceCollection service services.AddSingleton(); // IMPORTANT: Do NOT call BuildServiceProvider() here. - // The caller (App.InitializeServiceProvider) is responsible for building the single - // IServiceProvider and passing it to ServiceHost.Initialize() and InitializeCoreServices(). + // The caller is responsible for building the single IServiceProvider and passing it + // to ServiceHost.Initialize(). Log.Information("AddCoreServices completed."); return services; } - /// - /// Initializes core services after the ServiceProvider has been built. - /// All .Instance properties now resolve from ServiceHost directly — this method - /// only pre-resolves singletons and initializes TriggerManager subscriptions. - /// Must be called exactly once after BuildServiceProvider(). - /// - /// The single IServiceProvider instance - [Obsolete("All .Instance properties now resolve from ServiceHost directly. Only pre-resolves RealPluginManager and initializes TriggerManager.")] - public static void InitializeCoreServices(IServiceProvider provider) - { - Log.Information("InitializeCoreServices called (ServiceHost is the single source of truth)."); - - // All SetServiceProvider() calls are now no-ops. - // Keeping them for backward compatibility but they do nothing. -#pragma warning disable CS0618 // Suppress Obsolete warnings for SetServiceProvider calls - ConfigManager.SetServiceProvider(provider); - SecurityManager.SetServiceProvider(provider); - EventService.SetServiceProvider(provider); - PluginsManager.SetServiceProvider(provider); - ActivityManager.SetServiceProvider(provider); - StatisticsManager.SetServiceProvider(provider); - TasksManager.SetServiceProvider(provider); - FileWatcherManager.SetServiceProvider(provider); - KeyHookManager.SetServiceProvider(provider); - DevicesServer.SetServiceProvider(provider); - DevicesDiscoveryServer.SetServiceProvider(provider); - PluginsServer.SetServiceProvider(provider); - AnnouncementManager.SetServiceProvider(provider); - DevicesOrganizer.SetServiceProvider(provider); - TriggerManager.SetServiceProvider(provider); - WorkflowStorageService.SetServiceProvider(provider); - WorkflowScriptService.SetServiceProvider(provider); -#pragma warning restore CS0618 - - // Pre-resolve RealPluginManager to ensure it's initialized before PluginsServer.Run() is called. - Log.Information("Pre-resolving RealPluginManager to ensure single instance..."); - var rpm = provider.GetRequiredService(); - Log.Information("RealPluginManager pre-resolved. HashCode: {HashCode}", rpm.GetHashCode()); - - // Initialize TriggerManager from persisted workflow configurations - // so that trigger subscriptions are registered before plugins connect. - Log.Information("Initializing TriggerManager from persisted workflows..."); - var triggerManager = provider.GetRequiredService(); - triggerManager.InitializeFromPersistedWorkflows(); - - Log.Information("InitializeCoreServices completed."); - } } diff --git a/KitX Clients/KitX Core/KitX.Core/Device/DevicesDiscoveryServer.cs b/KitX Clients/KitX Core/KitX.Core/Device/DevicesDiscoveryServer.cs index 0c10a16c..2ad37bbd 100644 --- a/KitX Clients/KitX Core/KitX.Core/Device/DevicesDiscoveryServer.cs +++ b/KitX Clients/KitX Core/KitX.Core/Device/DevicesDiscoveryServer.cs @@ -38,12 +38,6 @@ public static DevicesDiscoveryServer Instance } } - /// - /// Kept for backward compatibility — ServiceHost is now the single source of truth. - /// - [Obsolete("ServiceHost is now the single source of truth. This method is a no-op.")] - internal static void SetServiceProvider(IServiceProvider? sp) { /* no-op */ } - private readonly IConfigService _configService; private UdpClient? _udpSender; private UdpClient? _udpReceiver; diff --git a/KitX Clients/KitX Core/KitX.Core/Device/DevicesOrganizer.cs b/KitX Clients/KitX Core/KitX.Core/Device/DevicesOrganizer.cs index e23d2d19..b0de7bd7 100644 --- a/KitX Clients/KitX Core/KitX.Core/Device/DevicesOrganizer.cs +++ b/KitX Clients/KitX Core/KitX.Core/Device/DevicesOrganizer.cs @@ -36,12 +36,6 @@ public static DevicesOrganizer Instance } } - /// - /// Kept for backward compatibility — ServiceHost is now the single source of truth. - /// - [Obsolete("ServiceHost is now the single source of truth. This method is a no-op.")] - internal static void SetServiceProvider(IServiceProvider? sp) { /* no-op */ } - /// /// Runs the devices organizer (ensures initialization via singleton access) /// diff --git a/KitX Clients/KitX Core/KitX.Core/Device/DevicesServer.cs b/KitX Clients/KitX Core/KitX.Core/Device/DevicesServer.cs index 6c1a5c84..f46d650c 100644 --- a/KitX Clients/KitX Core/KitX.Core/Device/DevicesServer.cs +++ b/KitX Clients/KitX Core/KitX.Core/Device/DevicesServer.cs @@ -50,12 +50,6 @@ public static DevicesServer Instance } } - /// - /// Kept for backward compatibility — ServiceHost is now the single source of truth. - /// - [Obsolete("ServiceHost is now the single source of truth. This method is a no-op.")] - internal static void SetServiceProvider(IServiceProvider? sp) { /* no-op */ } - private readonly Dictionary _signedDeviceTokens = new(); private IWebHost? _host; private int? _configuredPort; diff --git a/KitX Clients/KitX Core/KitX.Core/Device/PluginsServer.cs b/KitX Clients/KitX Core/KitX.Core/Device/PluginsServer.cs index 28173bfb..ce77d92c 100644 --- a/KitX Clients/KitX Core/KitX.Core/Device/PluginsServer.cs +++ b/KitX Clients/KitX Core/KitX.Core/Device/PluginsServer.cs @@ -44,12 +44,6 @@ public static PluginsServer Instance } } - /// - /// Kept for backward compatibility — ServiceHost is now the single source of truth. - /// - [Obsolete("ServiceHost is now the single source of truth. This method is a no-op.")] - internal static void SetServiceProvider(IServiceProvider? sp) { /* no-op, ServiceHost is used instead */ } - private WebSocketServer? _server; private readonly List _connections = new(); diff --git a/KitX Clients/KitX Core/KitX.Core/Event/EventService.cs b/KitX Clients/KitX Core/KitX.Core/Event/EventService.cs index 8093f489..9549a22c 100644 --- a/KitX Clients/KitX Core/KitX.Core/Event/EventService.cs +++ b/KitX Clients/KitX Core/KitX.Core/Event/EventService.cs @@ -28,12 +28,6 @@ public static EventService Instance } } - /// - /// Kept for backward compatibility — ServiceHost is now the single source of truth. - /// - [Obsolete("ServiceHost is now the single source of truth. This method is a no-op.")] - internal static void SetServiceProvider(IServiceProvider? sp) { /* no-op */ } - private readonly Dictionary>> _eventHandlers = new(); /// diff --git a/KitX Clients/KitX Core/KitX.Core/FileWatcher/FileWatcherManager.cs b/KitX Clients/KitX Core/KitX.Core/FileWatcher/FileWatcherManager.cs index 46806717..ea95d3e6 100644 --- a/KitX Clients/KitX Core/KitX.Core/FileWatcher/FileWatcherManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/FileWatcher/FileWatcherManager.cs @@ -28,12 +28,6 @@ public static FileWatcherManager Instance } } - /// - /// Kept for backward compatibility — ServiceHost is now the single source of truth. - /// - [Obsolete("ServiceHost is now the single source of truth. This method is a no-op.")] - internal static void SetServiceProvider(IServiceProvider? sp) { /* no-op */ } - private readonly Dictionary _watchers = new(); /// diff --git a/KitX Clients/KitX Core/KitX.Core/Hotkey/KeyHookManager.cs b/KitX Clients/KitX Core/KitX.Core/Hotkey/KeyHookManager.cs index 82ef564e..0a4af9e6 100644 --- a/KitX Clients/KitX Core/KitX.Core/Hotkey/KeyHookManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/Hotkey/KeyHookManager.cs @@ -31,12 +31,6 @@ public static KeyHookManager Instance } } - /// - /// Kept for backward compatibility — ServiceHost is now the single source of truth. - /// - [Obsolete("ServiceHost is now the single source of truth. This method is a no-op.")] - internal static void SetServiceProvider(IServiceProvider? sp) { /* no-op */ } - private const int KeysLimitation = 5; private readonly Queue _keyPressed = new(); diff --git a/KitX Clients/KitX Core/KitX.Core/Plugin/PluginsManager.cs b/KitX Clients/KitX Core/KitX.Core/Plugin/PluginsManager.cs index 65d2cfc2..fce2e34c 100644 --- a/KitX Clients/KitX Core/KitX.Core/Plugin/PluginsManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/Plugin/PluginsManager.cs @@ -29,28 +29,6 @@ namespace KitX.Core.Plugin; /// public class PluginsManager : IPluginService { - /// - /// Gets the singleton instance (resolves from ServiceHost when available). - /// Internal code should use constructor injection instead. - /// - public static PluginsManager Instance - { - get - { - if (ServiceHost.IsInitialized) - return (PluginsManager)ServiceHost.GetRequiredService(); - Log.Error("[PluginsManager] Instance: ServiceHost not initialized! Returning orphan instance — " + - "this indicates a DI initialization order bug. Use ServiceHost/constructor injection instead."); - return new PluginsManager(); - } - } - - /// - /// Kept for backward compatibility — ServiceHost is now the single source of truth. - /// - [Obsolete("ServiceHost is now the single source of truth. This method is a no-op.")] - internal static void SetServiceProvider(IServiceProvider? sp) { /* no-op */ } - private readonly List _plugins = new(); /// @@ -1471,4 +1449,4 @@ private void CleanupRegistration(string pluginName, EventHandler - /// Kept for backward compatibility — ServiceHost is now the single source of truth. - /// - [Obsolete("ServiceHost is now the single source of truth. This method is a no-op.")] - internal static void SetServiceProvider(IServiceProvider? sp) { /* no-op */ } - private RSA? _rsaInstance; private DeviceKey? _localDeviceKey; diff --git a/KitX Clients/KitX Core/KitX.Core/Statistics/StatisticsManager.cs b/KitX Clients/KitX Core/KitX.Core/Statistics/StatisticsManager.cs index 67687721..abaad3fe 100644 --- a/KitX Clients/KitX Core/KitX.Core/Statistics/StatisticsManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/Statistics/StatisticsManager.cs @@ -29,12 +29,6 @@ public static StatisticsManager Instance } } - /// - /// Kept for backward compatibility — ServiceHost is now the single source of truth. - /// - [Obsolete("ServiceHost is now the single source of truth. This method is a no-op.")] - internal static void SetServiceProvider(IServiceProvider? sp) { /* no-op */ } - private Dictionary? _useStatistics = []; /// diff --git a/KitX Clients/KitX Core/KitX.Core/Tasks/TasksManager.cs b/KitX Clients/KitX Core/KitX.Core/Tasks/TasksManager.cs index b9cf540c..93ab591a 100644 --- a/KitX Clients/KitX Core/KitX.Core/Tasks/TasksManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/Tasks/TasksManager.cs @@ -4,7 +4,6 @@ using KitX.Core.Contract.Tasks; using Serilog; using CTask = System.Threading.Tasks.Task; -using KitX.Core.DI; namespace KitX.Core.Tasks; @@ -13,28 +12,6 @@ namespace KitX.Core.Tasks; /// public class TasksManager : ITasksService { - /// - /// Gets the singleton instance (resolves from ServiceHost when available). - /// Internal code should use constructor injection instead. - /// - public static TasksManager Instance - { - get - { - if (ServiceHost.IsInitialized) - return (TasksManager)ServiceHost.GetRequiredService(); - Log.Error("[TasksManager] Instance: ServiceHost not initialized! Returning orphan instance — " + - "this indicates a DI initialization order bug. Use ServiceHost/constructor injection instead."); - return new TasksManager(); - } - } - - /// - /// Kept for backward compatibility — ServiceHost is now the single source of truth. - /// - [Obsolete("ServiceHost is now the single source of truth. This method is a no-op.")] - internal static void SetServiceProvider(IServiceProvider? sp) { /* no-op */ } - /// /// Creates a new tasks manager /// diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/TriggerManager.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/TriggerManager.cs index a797a0ed..165331ef 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/TriggerManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/TriggerManager.cs @@ -36,12 +36,6 @@ public static TriggerManager Instance } } - /// - /// Kept for backward compatibility — ServiceHost is now the single source of truth. - /// - [Obsolete("ServiceHost is now the single source of truth. This method is a no-op.")] - internal static void SetServiceProvider(IServiceProvider? sp) { /* no-op */ } - private readonly PluginsServer _pluginsServer; private readonly JsonSerializerOptions _serializerOptions = new() { diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs index f9f543c4..16871e9c 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs @@ -25,12 +25,6 @@ public class WorkflowScriptService : IWorkflowManagementService, IScriptExecutio /// public static WorkflowScriptService Instance { get; } = new(); - /// - /// Kept for backward compatibility — ServiceHost is now the single source of truth. - /// - [Obsolete("ServiceHost is now the single source of truth. This method is a no-op.")] - internal static void SetServiceProvider(IServiceProvider? sp) { /* no-op */ } - /// /// Shared runtime state across all workflow services. /// diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowStorageService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowStorageService.cs index c4df8899..d04ba07e 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowStorageService.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowStorageService.cs @@ -31,12 +31,6 @@ public static WorkflowStorageService Instance } } - /// - /// Kept for backward compatibility — ServiceHost is now the single source of truth. - /// - [Obsolete("ServiceHost is now the single source of truth. This method is a no-op.")] - internal static void SetServiceProvider(IServiceProvider? sp) { /* no-op */ } - private static readonly JsonSerializerOptions _jsonOptions = new() { WriteIndented = true diff --git a/KitX Clients/KitX Dashboard b/KitX Clients/KitX Dashboard index 37e430e4..409e383a 160000 --- a/KitX Clients/KitX Dashboard +++ b/KitX Clients/KitX Dashboard @@ -1 +1 @@ -Subproject commit 37e430e4a25209f732a65ddf7b37ff18bae6354f +Subproject commit 409e383adc52213eb69a79a951dce5cfc0e64a85 From fd3ebec897c6ccc78e0a841bb77b41c10b254399 Mon Sep 17 00:00:00 2001 From: StarInk Date: Sat, 2 May 2026 15:28:28 +0200 Subject: [PATCH 062/110] =?UTF-8?q?=F0=9F=A7=A9=20Refactor:=20Transition?= =?UTF-8?q?=20to=20Dependency=20Injection=20for=20Core=20Services?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../KitX.Core/Configuration/ConfigManager.cs | 1 + .../DI/CoreServiceCollectionExtensions.cs | 9 +- .../KitX Core/KitX.Core/Device/DeviceCase.cs | 27 +- .../Device/DevicesDiscoveryServer.cs | 34 +- .../KitX.Core/Device/DevicesOrganizer.cs | 34 +- .../KitX.Core/Device/DevicesServer.cs | 112 +++-- .../KitX.Core/Device/IPluginConnection.cs | 52 --- .../KitX.Core/Device/PluginConnection.cs | 36 +- .../KitX.Core/Device/PluginsServer.cs | 417 +++++++++--------- .../KitX Core/KitX.Core/Event/EventService.cs | 16 - .../KitX.Core/Plugin/PluginsManager.cs | 8 +- .../KitX.Core/Security/SecurityManager.cs | 73 ++- .../BuiltinFunctions/TryGetDeviceFunction.cs | 42 +- .../KitX.Core/Workflow/RealPluginManager.cs | 158 ++++--- .../KitX.Core/Workflow/TriggerManager.cs | 34 +- .../Workflow/WorkflowPluginService.cs | 8 +- KitX Clients/KitX Dashboard | 2 +- KitX Standard | 2 +- 18 files changed, 477 insertions(+), 588 deletions(-) delete mode 100644 KitX Clients/KitX Core/KitX.Core/Device/IPluginConnection.cs diff --git a/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigManager.cs b/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigManager.cs index 724f2222..dff62241 100644 --- a/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigManager.cs @@ -18,6 +18,7 @@ public class ConfigManager : IConfigService, IDisposable /// Gets the singleton instance. /// Uses static instance to maintain singleton behavior. /// + [Obsolete("Use DI container via ServiceHost.GetRequiredService() instead.", error: false)] public static ConfigManager Instance => _instance ??= new ConfigManager(); private string? _configLocation; diff --git a/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs b/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs index a8f2f78b..b7ed22ba 100644 --- a/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs +++ b/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs @@ -121,10 +121,11 @@ public static IServiceCollection AddCoreServices(this IServiceCollection service Log.Information("Registering RealPluginManager..."); services.AddSingleton(provider => { - var pluginsServer = provider.GetRequiredService() as PluginsServer; - if (pluginsServer == null) - throw new InvalidOperationException("IPluginServer must be registered as PluginsServer"); - return new RealPluginManager(pluginsServer, provider.GetRequiredService()); + var pluginServer = provider.GetRequiredService(); + var eventService = provider.GetRequiredService(); + var deviceDiscoveryService = provider.GetRequiredService(); + var deviceServer = provider.GetRequiredService(); + return new RealPluginManager(pluginServer, eventService, deviceDiscoveryService, deviceServer, provider.GetRequiredService()); }); // RealPluginManager is pre-resolved by the caller after BuildServiceProvider(). diff --git a/KitX Clients/KitX Core/KitX.Core/Device/DeviceCase.cs b/KitX Clients/KitX Core/KitX.Core/Device/DeviceCase.cs index ffece95a..d7447b53 100644 --- a/KitX Clients/KitX Core/KitX.Core/Device/DeviceCase.cs +++ b/KitX Clients/KitX Core/KitX.Core/Device/DeviceCase.cs @@ -17,23 +17,32 @@ public class DeviceCase : IDeviceCase { private readonly IConfigService _configService; private readonly IDeviceKeyService _securityService; - private readonly DevicesServer _devicesServer; + private readonly IDeviceServer _devicesServer; + private readonly IDeviceDiscoveryService _deviceDiscoveryService; /// - /// Creates a new device case + /// Creates a new device case with dependency injection /// - public DeviceCase() : this(new DeviceInfo()) { } + public DeviceCase(IConfigService configService, IDeviceKeyService securityService, IDeviceServer devicesServer, IDeviceDiscoveryService deviceDiscoveryService) + : this(new DeviceInfo(), configService, securityService, devicesServer, deviceDiscoveryService) + { + } /// - /// Creates a new device case with device info + /// Creates a new device case with device info and dependency injection /// /// Device information - public DeviceCase(DeviceInfo deviceInfo) + /// Configuration service + /// Security service + /// Devices server + /// Device discovery service + public DeviceCase(DeviceInfo deviceInfo, IConfigService configService, IDeviceKeyService securityService, IDeviceServer devicesServer, IDeviceDiscoveryService deviceDiscoveryService) { DeviceInfo = deviceInfo; - _configService = ConfigManager.Instance; - _securityService = SecurityManager.Instance; - _devicesServer = DevicesServer.Instance; + _configService = configService ?? throw new ArgumentNullException(nameof(configService)); + _securityService = securityService ?? throw new ArgumentNullException(nameof(securityService)); + _devicesServer = devicesServer ?? throw new ArgumentNullException(nameof(devicesServer)); + _deviceDiscoveryService = deviceDiscoveryService ?? throw new ArgumentNullException(nameof(deviceDiscoveryService)); } /// @@ -59,7 +68,7 @@ public DeviceCase(DeviceInfo deviceInfo) /// /// Gets a value indicating whether this is the current device /// - public bool IsCurrentDevice => DeviceInfo.IsCurrentDevice(DevicesDiscoveryServer.Instance.DefaultDeviceInfo); + public bool IsCurrentDevice => DeviceInfo.IsCurrentDevice(_deviceDiscoveryService.DefaultDeviceInfo); /// /// Connection token for authenticated communication diff --git a/KitX Clients/KitX Core/KitX.Core/Device/DevicesDiscoveryServer.cs b/KitX Clients/KitX Core/KitX.Core/Device/DevicesDiscoveryServer.cs index 2ad37bbd..d39b119e 100644 --- a/KitX Clients/KitX Core/KitX.Core/Device/DevicesDiscoveryServer.cs +++ b/KitX Clients/KitX Core/KitX.Core/Device/DevicesDiscoveryServer.cs @@ -11,9 +11,12 @@ using CTask = System.Threading.Tasks.Task; using KitX.Core.Contract.Configuration; using KitX.Core.Contract.Device; +using KitX.Core.Contract.Event; +using KitX.Core.Contract.Plugin; using KitX.Shared.CSharp.Device; using Serilog; using KitX.Core.DI; +using KitX.Core.Event; namespace KitX.Core.Device; @@ -22,23 +25,9 @@ namespace KitX.Core.Device; /// public class DevicesDiscoveryServer : ServerBase, IDeviceDiscoveryService { - /// - /// Gets the singleton instance (resolves from ServiceHost when available). - /// Internal code should use constructor injection instead. - /// - public static DevicesDiscoveryServer Instance - { - get - { - if (ServiceHost.IsInitialized) - return (DevicesDiscoveryServer)ServiceHost.GetRequiredService(); - Log.Error("[DevicesDiscoveryServer] Instance: ServiceHost not initialized! Returning orphan instance — " + - "this indicates a DI initialization order bug. Use ServiceHost/constructor injection instead."); - return new DevicesDiscoveryServer(); - } - } - private readonly IConfigService _configService; + private readonly IEventService _eventService; + private readonly IPluginServer _pluginServer; private UdpClient? _udpSender; private UdpClient? _udpReceiver; private System.Timers.Timer? _udpSendTimer; @@ -94,11 +83,16 @@ public void ConfigurePort(int port) #pragma warning restore CS0067 /// - /// Creates a new device discovery server + /// Creates a new device discovery server with dependency injection /// - public DevicesDiscoveryServer() + /// The configuration service + /// The event service for publishing events + /// The plugin server for querying connection info + public DevicesDiscoveryServer(IConfigService configService, IEventService eventService, IPluginServer pluginServer) { - _configService = ConfigManager.Instance; // Will be injected via DI in production + _configService = configService ?? throw new ArgumentNullException(nameof(configService)); + _eventService = eventService ?? throw new ArgumentNullException(nameof(eventService)); + _pluginServer = pluginServer ?? throw new ArgumentNullException(nameof(pluginServer)); DefaultDeviceInfo = NetworkHelper.GetDeviceInfo(); // Note: DevicesOrganizer.Run() should be called after services are fully initialized @@ -246,7 +240,7 @@ private void UpdateDefaultDeviceInfo() DefaultDeviceInfo.Device.ResetIPv4(NetworkHelper.GetInterNetworkIPv4()) .ResetIPv6(NetworkHelper.GetInterNetworkIPv6()); DefaultDeviceInfo.PluginsServerPort = ConstantTable.PluginsServerPort; - DefaultDeviceInfo.PluginsCount = PluginsServer.Instance?.Connections?.Count ?? 0; + DefaultDeviceInfo.PluginsCount = _pluginServer.Connections?.Count ?? 0; DefaultDeviceInfo.DevicesServerPort = ConstantTable.DevicesServerPort; DefaultDeviceInfo.DevicesServerBuildTime = ConstantTable.ServerBuildTime; diff --git a/KitX Clients/KitX Core/KitX.Core/Device/DevicesOrganizer.cs b/KitX Clients/KitX Core/KitX.Core/Device/DevicesOrganizer.cs index b0de7bd7..1c7f9377 100644 --- a/KitX Clients/KitX Core/KitX.Core/Device/DevicesOrganizer.cs +++ b/KitX Clients/KitX Core/KitX.Core/Device/DevicesOrganizer.cs @@ -20,30 +20,9 @@ namespace KitX.Core.Device; /// public class DevicesOrganizer : IDevicesOrganizer { - /// - /// Gets the singleton instance (resolves from ServiceHost when available). - /// Internal code should use constructor injection instead. - /// - public static DevicesOrganizer Instance - { - get - { - if (ServiceHost.IsInitialized) - return ServiceHost.GetRequiredService(); - Log.Error("[DevicesOrganizer] Instance: ServiceHost not initialized! Returning orphan instance — " + - "this indicates a DI initialization order bug. Use ServiceHost/constructor injection instead."); - return new DevicesOrganizer(); - } - } - - /// - /// Runs the devices organizer (ensures initialization via singleton access) - /// - /// The singleton instance - public static DevicesOrganizer Run() => Instance; - private readonly IConfigService _configService; private readonly IEventService _eventService; + private readonly IDeviceDiscoveryService _deviceDiscoveryService; private readonly object _receivedDeviceInfo4WatchLock = new(); private readonly Queue _deviceInfosQueue = new(); private readonly object _addDeviceCardLock = new(); @@ -61,12 +40,13 @@ public static DevicesOrganizer Instance public event EventHandler? DeviceOffline; /// - /// Creates a new devices organizer + /// Creates a new devices organizer with dependency injection /// - public DevicesOrganizer() + public DevicesOrganizer(IConfigService configService, IEventService eventService, IDeviceDiscoveryService deviceDiscoveryService) { - _configService = ConfigManager.Instance; - _eventService = EventService.Instance; + _configService = configService ?? throw new ArgumentNullException(nameof(configService)); + _eventService = eventService ?? throw new ArgumentNullException(nameof(eventService)); + _deviceDiscoveryService = deviceDiscoveryService ?? throw new ArgumentNullException(nameof(deviceDiscoveryService)); Initialize(); } @@ -88,7 +68,7 @@ private void Initialize() private void InitEvents() { // Subscribe to device discovery events from DevicesDiscoveryServer - DevicesDiscoveryServer.Instance.DeviceDiscovered += (_, args) => + _deviceDiscoveryService.DeviceDiscovered += (_, args) => { if (args.DeviceInfo is null) return; diff --git a/KitX Clients/KitX Core/KitX.Core/Device/DevicesServer.cs b/KitX Clients/KitX Core/KitX.Core/Device/DevicesServer.cs index f46d650c..0d898177 100644 --- a/KitX Clients/KitX Core/KitX.Core/Device/DevicesServer.cs +++ b/KitX Clients/KitX Core/KitX.Core/Device/DevicesServer.cs @@ -9,8 +9,10 @@ using System.Threading; using System.Threading.Tasks; using KitX.Core.Contract.Device; +using KitX.Core.Contract.Event; using KitX.Core.Contract.Plugin; using KitX.Core.Contract.Plugin.Events; +using KitX.Core.Contract.Security; using KitX.Core.Event; using KitX.Core.Security; using KitX.Shared.CSharp.Device; @@ -34,21 +36,11 @@ namespace KitX.Core.Device; /// public class DevicesServer : ServerBase, IDeviceServer { - /// - /// Gets the singleton instance (resolves from ServiceHost when available). - /// Internal code should use constructor injection instead. - /// - public static DevicesServer Instance - { - get - { - if (ServiceHost.IsInitialized) - return (DevicesServer)ServiceHost.GetRequiredService(); - Log.Error("[DevicesServer] Instance: ServiceHost not initialized! Returning orphan instance — " + - "this indicates a DI initialization order bug. Use ServiceHost/constructor injection instead."); - return new DevicesServer(); - } - } + private readonly IEncryptionService _encryptionService; + private readonly IDeviceKeyService _deviceKeyService; + private readonly IEventService _eventService; + private readonly IPluginServer _pluginServer; + private readonly IDeviceDiscoveryService _deviceDiscoveryService; private readonly Dictionary _signedDeviceTokens = new(); private IWebHost? _host; @@ -123,9 +115,26 @@ public static DevicesServer Instance #pragma warning restore CS0067 /// - /// Creates a new device server + /// Creates a new device server with all dependencies injected. /// - public DevicesServer() { } + /// Encryption service for cryptographic operations + /// Device key management service + /// Event service for publishing events + /// Plugin server for managing plugin connections + /// Device discovery service + public DevicesServer( + IEncryptionService encryptionService, + IDeviceKeyService deviceKeyService, + IEventService eventService, + IPluginServer pluginServer, + IDeviceDiscoveryService deviceDiscoveryService) + { + _encryptionService = encryptionService ?? throw new ArgumentNullException(nameof(encryptionService)); + _deviceKeyService = deviceKeyService ?? throw new ArgumentNullException(nameof(deviceKeyService)); + _eventService = eventService ?? throw new ArgumentNullException(nameof(eventService)); + _pluginServer = pluginServer ?? throw new ArgumentNullException(nameof(pluginServer)); + _deviceDiscoveryService = deviceDiscoveryService ?? throw new ArgumentNullException(nameof(deviceDiscoveryService)); + } /// /// Gets or sets the port @@ -252,7 +261,7 @@ public IDeviceServer Run() ConstantTable.DevicesServerPort = Port ?? 0; // Publish port changed event via EventService only (removed direct PortChanged event to avoid potential recursion) - EventService.Instance.Publish(EventNames.DevicesServerPortChanged, new PortChangedEventArgs { Port = Port ?? 0 }); + _eventService?.Publish(EventNames.DevicesServerPortChanged, new PortChangedEventArgs { Port = Port ?? 0 }); Log.Information($"DevicesServer started on port {Port}"); } @@ -354,6 +363,21 @@ public async System.Threading.Tasks.Task CloseAsync() /// True if the device is signed in public bool IsDeviceSignedIn(DeviceLocator locator) => _signedDeviceTokens.ContainsKey(locator); + /// + /// Gets the signed device token for a device locator + /// + /// The device locator + /// The token or null if not found + public string? GetDeviceToken(DeviceLocator locator) => + _signedDeviceTokens.TryGetValue(locator, out var token) ? token : null; + + /// + /// Gets all signed-in device locators + /// + /// Read-only list of signed-in device locators + public IReadOnlyList GetSignedInDevices() => + _signedDeviceTokens.Keys.ToList().AsReadOnly(); + /// /// Adds a device token /// @@ -397,7 +421,7 @@ private async System.Threading.Tasks.Task HandleGetDeviceInfoAsync(HttpContext c if (IsDeviceTokenExist(token)) { - var deviceInfo = DevicesDiscoveryServer.Instance?.DefaultDeviceInfo; + var deviceInfo = _deviceDiscoveryService.DefaultDeviceInfo; if (deviceInfo != null) { context.Response.ContentType = "application/json"; @@ -448,8 +472,17 @@ private async System.Threading.Tasks.Task HandleExchangeKeyAsync(HttpContext con return; } - var securityService = SecurityManager.Instance; - if (securityService.LocalDeviceKey == null) + var securityService = _encryptionService; + var deviceKeyService = _deviceKeyService; + if (securityService == null) + { + context.Response.StatusCode = 500; + await context.Response.WriteAsync("Encryption service not available"); + return; + } + + // LocalDeviceKey is on IDeviceKeyService, check via GetPrivateDeviceKey + if (deviceKeyService.GetPrivateDeviceKey() == null) { context.Response.StatusCode = 400; await context.Response.WriteAsync("Remote device didn't set up device key."); @@ -484,7 +517,7 @@ private async System.Threading.Tasks.Task HandleExchangeKeyAsync(HttpContext con _exchangeKeyTcs = new TaskCompletionSource(); // Publish event for UI to handle — requires user confirmation - EventService.Instance.Publish(EventNames.OnReceiveExchangeDeviceKey, + _eventService.Publish(EventNames.OnReceiveExchangeDeviceKey, new ExchangeDeviceKeyEventArgs { VerificationCode = _exchangeDeviceKeyCode, @@ -543,14 +576,14 @@ private async System.Threading.Tasks.Task HandleExchangeKeyAsync(HttpContext con } // Add device key - securityService.AddDeviceKey( + deviceKeyService.AddDeviceKey( deviceKeyInstance.Device.MacAddress, deviceKeyInstance.Device.DeviceName, deviceKeyInstance.RsaPublicKeyPem ?? "" ); // Send back local key - var currentKey = securityService.GetPrivateDeviceKey(); + var currentKey = deviceKeyService.GetPrivateDeviceKey(); if (currentKey == null) { _isExchangingDeviceKey = false; @@ -569,7 +602,7 @@ private async System.Threading.Tasks.Task HandleExchangeKeyAsync(HttpContext con _pendingExchangeRequest = null; // Publish accept event - EventService.Instance.Publish(EventNames.OnAcceptingDeviceKey, EventArgs.Empty); + _eventService.Publish(EventNames.OnAcceptingDeviceKey, EventArgs.Empty); context.Response.ContentType = "application/json"; await context.Response.WriteAsync(JsonSerializer.Serialize(currentKeyEncrypted)); @@ -605,7 +638,8 @@ private async System.Threading.Tasks.Task HandleExchangeKeyBackAsync(HttpContext using var reader = new StreamReader(context.Request.Body); var encryptedKey = await reader.ReadToEndAsync(); - var securityService = SecurityManager.Instance; + var securityService = _encryptionService; + var deviceKeyService = _deviceKeyService; var deviceKeyDecrypted = securityService.AesDecrypt(encryptedKey, _exchangeDeviceKeyCode); var deviceKeyInstance = JsonSerializer.Deserialize(deviceKeyDecrypted); @@ -617,7 +651,7 @@ private async System.Threading.Tasks.Task HandleExchangeKeyBackAsync(HttpContext } // Add device key - securityService.AddDeviceKey( + deviceKeyService.AddDeviceKey( deviceKeyInstance.Device.MacAddress, deviceKeyInstance.Device.DeviceName, deviceKeyInstance.RsaPublicKeyPem ?? "" @@ -626,7 +660,7 @@ private async System.Threading.Tasks.Task HandleExchangeKeyBackAsync(HttpContext _exchangeDeviceKeyCode = null; // Publish accept event - EventService.Instance.Publish(EventNames.OnAcceptingDeviceKey, EventArgs.Empty); + _eventService.Publish(EventNames.OnAcceptingDeviceKey, EventArgs.Empty); context.Response.StatusCode = 200; await context.Response.WriteAsync("OK"); @@ -652,7 +686,7 @@ private async System.Threading.Tasks.Task HandleCancelExchangingKeyAsync(HttpCon return; } - EventService.Instance.Publish(EventNames.OnReceiveCancelExchangingDeviceKey, EventArgs.Empty); + _eventService.Publish(EventNames.OnReceiveCancelExchangingDeviceKey, EventArgs.Empty); // Cancel any pending user confirmation _exchangeKeyTcs?.TrySetCanceled(); @@ -734,8 +768,9 @@ private async System.Threading.Tasks.Task HandleConnectAsync(HttpContext context } // Search for device key - var securityService = SecurityManager.Instance; - var key = securityService.SearchDeviceKey(device); + var securityService = _encryptionService; + var deviceKeyService = _deviceKeyService; + var key = deviceKeyService.SearchDeviceKey(device); if (key == null) { @@ -883,7 +918,7 @@ private async Task HandlePluginInvokeAsync(HttpContext context) } // 7. Find local plugin connection by PluginConnectionId - var connector = PluginsServer.Instance.FindConnection(command.PluginConnectionId); + var connector = _pluginServer.FindConnection(command.PluginConnectionId); if (connector == null) { Log.Warning("[{Location}] Plugin connection not found: {ConnectionId}", @@ -905,12 +940,12 @@ void OnResponse(object? sender, PluginResponseEventArgs e) { if (e.RequestId == requestId) { - PluginsServer.Instance.PluginResponse -= OnResponse; + _pluginServer.PluginResponse -= OnResponse; _pendingPluginResponses.TryRemove(requestId, out _); tcs.TrySetResult(e.Content); } } - PluginsServer.Instance.PluginResponse += OnResponse; + _pluginServer.PluginResponse += OnResponse; _pendingPluginResponses[requestId] = tcs; // 9. Build the request to send to plugin (manual copy since Request is class not record) @@ -945,7 +980,7 @@ void OnResponse(object? sender, PluginResponseEventArgs e) { Log.Warning("[{Location}] Plugin invoke timed out, RequestId: {RequestId}", location, requestId); _pendingPluginResponses.TryRemove(requestId, out _); - PluginsServer.Instance.PluginResponse -= OnResponse; + _pluginServer.PluginResponse -= OnResponse; context.Response.StatusCode = 504; await context.Response.WriteAsync("Plugin invocation timed out"); } @@ -953,7 +988,7 @@ void OnResponse(object? sender, PluginResponseEventArgs e) { Log.Warning("[{Location}] Plugin invoke cancelled, RequestId: {RequestId}", location, requestId); _pendingPluginResponses.TryRemove(requestId, out _); - PluginsServer.Instance.PluginResponse -= OnResponse; + _pluginServer.PluginResponse -= OnResponse; context.Response.StatusCode = 499; await context.Response.WriteAsync("Plugin invocation cancelled"); } @@ -982,7 +1017,7 @@ private string DecryptContent(Request request, string token) var device = SearchDeviceByToken(token); if (device != null) { - var key = SecurityManager.Instance.SearchDeviceKey(device); + var key = _deviceKeyService.SearchDeviceKey(device); if (key != null) { try @@ -990,7 +1025,7 @@ private string DecryptContent(Request request, string token) var encryptedContent = JsonSerializer.Deserialize(content, SerializerOptions); if (encryptedContent != null) { - content = SecurityManager.Instance.RsaDecryptContent(key, encryptedContent) ?? content; + content = _encryptionService.RsaDecryptContent(key, encryptedContent) ?? content; } } catch (Exception ex) @@ -1003,4 +1038,5 @@ private string DecryptContent(Request request, string token) return content; } + } diff --git a/KitX Clients/KitX Core/KitX.Core/Device/IPluginConnection.cs b/KitX Clients/KitX Core/KitX.Core/Device/IPluginConnection.cs deleted file mode 100644 index d1a25280..00000000 --- a/KitX Clients/KitX Core/KitX.Core/Device/IPluginConnection.cs +++ /dev/null @@ -1,52 +0,0 @@ -using System; -using KitX.Shared.CSharp.Plugin; -using CTask = System.Threading.Tasks.Task; - -namespace KitX.Core.Device; - -/// -/// Plugin connection interface -/// -public interface IPluginConnection -{ - /// - /// Gets the connection ID - /// - string? ConnectionId { get; } - - /// - /// Gets or sets the plugin info - /// - PluginInfo? PluginInfo { get; set; } - - /// - /// Gets the connection status - /// - ServerStatus Status { get; } - - /// - /// Event raised when a message is received - /// - event EventHandler? MessageReceived; - - /// - /// Event raised when connection is closed - /// - event EventHandler? Closed; - - /// - /// Initializes the connection - /// - void Initialize(); - - /// - /// Sends a message - /// - /// The message to send - void Send(string message); - - /// - /// Closes the connection - /// - CTask CloseAsync(); -} \ No newline at end of file diff --git a/KitX Clients/KitX Core/KitX.Core/Device/PluginConnection.cs b/KitX Clients/KitX Core/KitX.Core/Device/PluginConnection.cs index 8f979178..a06229af 100644 --- a/KitX Clients/KitX Core/KitX.Core/Device/PluginConnection.cs +++ b/KitX Clients/KitX Core/KitX.Core/Device/PluginConnection.cs @@ -3,6 +3,7 @@ using Fleck; using KitX.Core.Contract.Plugin; using KitX.Core.Contract.Plugin.Events; +using KitX.Core.Contract.Device; using KitX.Shared.CSharp.Plugin; using KitX.Shared.CSharp.WebCommand; using Serilog; @@ -12,30 +13,25 @@ namespace KitX.Core.Device; /// /// Plugin connection implementation /// -public class PluginConnection : IPluginConnection, IPluginConnector +public class PluginConnection : KitX.Core.Contract.Plugin.IPluginConnection { private readonly IWebSocketConnection _connection; - private ServerStatus _status = ServerStatus.Pending; + private KitX.Core.Contract.Device.ServerStatus _statusBackingField = KitX.Core.Contract.Device.ServerStatus.Pending; /// /// Gets the connection ID /// public string? ConnectionId { get; private set; } - /// - /// IPluginConnector.ConnectionId — non-nullable explicit implementation - /// - string IPluginConnector.ConnectionId => ConnectionId!; - /// /// Gets or sets the plugin info /// - public PluginInfo? PluginInfo { get; set; } + public KitX.Shared.CSharp.Plugin.PluginInfo? PluginInfo { get; set; } /// /// Gets the connection status /// - public ServerStatus Status => _status; + public KitX.Core.Contract.Device.ServerStatus Status => _statusBackingField; /// /// Event raised when a message is received @@ -50,12 +46,12 @@ public class PluginConnection : IPluginConnection, IPluginConnector /// /// Event raised when a plugin response is received (IPluginConnector implementation) /// - public event EventHandler? PluginResponse; + public event EventHandler? PluginResponse; /// /// Event raised when plugin reports status (IPluginConnector implementation) /// - public event EventHandler? StatusReport; + public event EventHandler? StatusReport; /// /// Constructor @@ -75,11 +71,11 @@ public void Initialize() { _connection.OnOpen = () => { - _status = ServerStatus.Running; + _statusBackingField = KitX.Core.Contract.Device.ServerStatus.Running; StatusReport?.Invoke(this, new PluginStatusReportEventArgs { ConnectionId = ConnectionId!, - Status = ServerStatus.Running.ToString() + Status = KitX.Core.Contract.Device.ServerStatus.Running.ToString() }); }; @@ -96,7 +92,7 @@ public void Initialize() command.Tags.TryGetValue("RequestId", out var requestId)) { // This is a plugin response - trigger PluginResponse event - PluginResponse?.Invoke(this, new PluginResponseEventArgs + PluginResponse?.Invoke(this, new KitX.Core.Contract.Plugin.Events.PluginResponseEventArgs { RequestId = requestId, Content = kwc.Content @@ -116,22 +112,22 @@ public void Initialize() _connection.OnClose = () => { - _status = ServerStatus.Pending; - StatusReport?.Invoke(this, new PluginStatusReportEventArgs + _statusBackingField = KitX.Core.Contract.Device.ServerStatus.Pending; + StatusReport?.Invoke(this, new KitX.Core.Contract.Plugin.Events.PluginStatusReportEventArgs { ConnectionId = ConnectionId!, - Status = ServerStatus.Pending.ToString() + Status = KitX.Core.Contract.Device.ServerStatus.Pending.ToString() }); Closed?.Invoke(this, EventArgs.Empty); }; _connection.OnError = ex => { - _status = ServerStatus.Errored; - StatusReport?.Invoke(this, new PluginStatusReportEventArgs + _statusBackingField = KitX.Core.Contract.Device.ServerStatus.Errored; + StatusReport?.Invoke(this, new KitX.Core.Contract.Plugin.Events.PluginStatusReportEventArgs { ConnectionId = ConnectionId!, - Status = ServerStatus.Errored.ToString() + Status = KitX.Core.Contract.Device.ServerStatus.Errored.ToString() }); Serilog.Log.Error(ex, $"PluginConnection error for {ConnectionId}, triggering Closed event"); diff --git a/KitX Clients/KitX Core/KitX.Core/Device/PluginsServer.cs b/KitX Clients/KitX Core/KitX.Core/Device/PluginsServer.cs index ce77d92c..9c5e4565 100644 --- a/KitX Clients/KitX Core/KitX.Core/Device/PluginsServer.cs +++ b/KitX Clients/KitX Core/KitX.Core/Device/PluginsServer.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Text.Json; @@ -8,6 +8,7 @@ using KitX.Core; using KitX.Core.Contract.Plugin; using KitX.Core.Contract.Plugin.Events; +using KitX.Core.Contract.Event; using KitX.Core.Event; using KitX.Core.DI; using KitX.Core.Plugin; @@ -15,7 +16,10 @@ using KitX.Shared.CSharp.WebCommand; using Serilog; using CTask = System.Threading.Tasks.Task; -using KitX.Core.Device.Events; +using IPluginConnection = KitX.Core.Contract.Plugin.IPluginConnection; +using PluginConnectedEventArgs = KitX.Core.Contract.Plugin.Events.PluginConnectedEventArgs; +using PluginDisconnectedEventArgs = KitX.Core.Contract.Plugin.Events.PluginDisconnectedEventArgs; +using PluginMessageReceivedEventArgs = KitX.Core.Contract.Plugin.Events.PluginMessageReceivedEventArgs; namespace KitX.Core.Device; @@ -24,28 +28,9 @@ namespace KitX.Core.Device; /// public class PluginsServer : ServerBase, IPluginServer { - /// - /// Gets the singleton instance (resolves from ServiceHost when available). - /// Internal code should use constructor injection instead. - /// - public static PluginsServer Instance - { - get - { - if (DI.ServiceHost.IsInitialized) - { - var instance = (PluginsServer)DI.ServiceHost.GetRequiredService(); - Log.Information("[PluginsServer] Instance: resolved from ServiceHost. HashCode: {HashCode}", instance.GetHashCode()); - return instance; - } - Log.Error("[PluginsServer] Instance: ServiceHost not initialized! Returning orphan instance — " + - "this indicates a DI initialization order bug. Use ServiceHost/constructor injection instead."); - return new PluginsServer(); - } - } - + private readonly IEventService _eventService; private WebSocketServer? _server; - private readonly List _connections = new(); + private readonly List _connections = new(); /// /// JSON serializer options (accessible from PluginConnection) @@ -62,6 +47,20 @@ public static PluginsServer Instance /// public int? Port { get; private set; } + /// + /// Configured port for the server + /// + private int? _configuredPort; + + /// + /// Configures the port for the server + /// + /// The port number + public void ConfigurePort(int port) + { + _configuredPort = port is >= 0 and <= 65535 ? port : null; + } + /// /// Event raised when server port changes /// @@ -70,15 +69,10 @@ public static PluginsServer Instance #pragma warning restore CS0067 /// - /// Gets the list of plugin connections - /// - public IReadOnlyList Connections => _connections.AsReadOnly(); - - /// - /// IPluginServer.Connections — returns connected plugins as IPluginConnector list + /// IPluginServer.Connections — returns connected plugins as IPluginConnection list /// - IReadOnlyList IPluginServer.Connections => - _connections.Cast().ToList().AsReadOnly(); + IReadOnlyList IPluginServer.Connections => + _connections.ToList().AsReadOnly(); /// /// Event raised when a plugin connects @@ -111,10 +105,14 @@ public static PluginsServer Instance public event EventHandler? PluginResponse; /// - /// Creates a new plugins server + /// Creates a new plugins server with dependency injection /// - public PluginsServer() + /// The event service for publishing events + public PluginsServer(IEventService eventService) { + _eventService = eventService ?? throw new ArgumentNullException(nameof(eventService)); + Log.Information("[PluginsServer] Constructor called. HashCode: {HashCode}, EventService HashCode: {EventServiceHashCode}", + GetHashCode(), eventService.GetHashCode()); } /// @@ -127,236 +125,221 @@ public IPluginServer Run() return this; const int maxRetries = 10; - const int startPort = 7777; - int currentPort = startPort; + const int defaultBasePort = 7777; + + int basePort = defaultBasePort; + if (_configuredPort > 0) + basePort = _configuredPort.Value; + else if (ConstantTable.PluginsServerPort > 0) + basePort = ConstantTable.PluginsServerPort; + bool serverStarted = false; - for (int retryCount = 0; retryCount < maxRetries && !serverStarted; retryCount++) + int currentPort = basePort; + StartServer(ref serverStarted, ref currentPort); + + for (int retryCount = 1; retryCount < maxRetries && !serverStarted; retryCount++) { - try - { - // Determine port for this attempt - if (ConstantTable.PluginsServerPort <= 0) - { - currentPort = startPort + retryCount; - // Accept connections on all network interfaces (legacy protocol compatible) - _server = new WebSocketServer($"ws://0.0.0.0:{currentPort}"); - } - else - { - // Use configured port - currentPort = ConstantTable.PluginsServerPort; - _server = new WebSocketServer($"ws://0.0.0.0:{currentPort}"); - } + currentPort = basePort + retryCount; + StartServer(ref serverStarted, ref currentPort); + } - _server!.Start(socket => - { - var connectionId = socket.ConnectionInfo.Path.Trim('/'); + if (!serverStarted) + { + Log.Warning("[PluginsServer] All sequential port attempts failed, falling back to system-assigned port (0)"); + currentPort = 0; + StartServer(ref serverStarted, ref currentPort); + } - if (RegexToVerifyConnectionId().IsMatch(connectionId) == false) - { - socket.Send("Invalid connection id."); - socket.Close(); - return; - } + if (!serverStarted) + { + Log.Error($"Failed to start PluginsServer after {maxRetries} sequential attempts and system-assigned port fallback"); + SetErrored(null, nameof(PluginsServer)); + return this; + } - Log.Information($"[PluginsServer] About to add connection {connectionId}. _connections count before: {_connections.Count}, this HashCode: {GetHashCode()}"); - var connection = new PluginConnection(socket, connectionId); - _connections.Add(connection); - Log.Information($"[PluginsServer] Added connection {connectionId}. _connections count after: {_connections.Count}"); + SetRunning(); - // Handle connection closed - connection.Closed += (sender, args) => - { - _connections.Remove(connection); + return this; + } + + private void StartServer(ref bool serverStarted, ref int currentPort) + { + try + { + _server = new WebSocketServer($"ws://0.0.0.0:{currentPort}"); - Log.Information($"[PluginsServer] Connection closed: {connectionId}, PluginInfo: {connection.PluginInfo?.Name}"); + _server!.Start(socket => + { + var connectionId = socket.ConnectionInfo.Path.Trim('/'); - // Trigger PluginUnregistered if this connection had a registered plugin - if (connection.PluginInfo is not null) - { - Log.Information($"[PluginsServer] Publishing PluginUnregistered for: {connection.PluginInfo.Name}"); + if (RegexToVerifyConnectionId().IsMatch(connectionId) == false) + { + socket.Send("Invalid connection id."); + socket.Close(); + return; + } - // Publish event via EventService only (public event removed to eliminate dual-invocation pattern) - EventService.Instance.Publish(EventNames.PluginUnregistered, new PluginUnregisteredEventArgs - { - PluginInfo = connection.PluginInfo - }); - } + Log.Information($"[PluginsServer] About to add connection {connectionId}. _connections count before: {_connections.Count}, this HashCode: {GetHashCode()}"); + var connection = new PluginConnection(socket, connectionId); + _connections.Add(connection); + Log.Information($"[PluginsServer] Added connection {connectionId}. _connections count after: {_connections.Count}"); - Log.Information($"[PluginsServer] Publishing PluginDisconnected for: {connectionId}"); + connection.Closed += (sender, args) => + { + _connections.Remove(connection); + + Log.Information($"[PluginsServer] Connection closed: {connectionId}, PluginInfo: {connection.PluginInfo?.Name}"); + + if (connection.PluginInfo is not null) + { + Log.Information($"[PluginsServer] Publishing PluginUnregistered for: {connection.PluginInfo.Name}"); - // Publish event via EventService only - EventService.Instance.Publish(EventNames.PluginDisconnected, new PluginConnectionEventArgs + _eventService.Publish(EventNames.PluginUnregistered, new PluginUnregisteredEventArgs { - ConnectionId = connectionId, PluginInfo = connection.PluginInfo }); - }; + } + + Log.Information($"[PluginsServer] Publishing PluginDisconnected for: {connectionId}"); - connection.MessageReceived += (sender, message) => + _eventService.Publish(EventNames.PluginDisconnected, new PluginConnectionEventArgs { - try + ConnectionId = connectionId, + PluginInfo = connection.PluginInfo + }); + }; + + connection.MessageReceived += (sender, message) => + { + try + { + var kwc = System.Text.Json.JsonSerializer.Deserialize(message); + if (kwc?.Content is not null) { - var kwc = System.Text.Json.JsonSerializer.Deserialize(message); - if (kwc?.Content is not null) + var cmd = System.Text.Json.JsonSerializer.Deserialize(kwc.Content); + Log.Information($"[PluginsServer] MessageReceived: cmd.Request = {cmd.Request}, expected = {KitX.Shared.CSharp.WebCommand.Infos.CommandRequestInfo.RegisterPlugin}"); + if (cmd.Request == KitX.Shared.CSharp.WebCommand.Infos.CommandRequestInfo.RegisterPlugin) { - var cmd = System.Text.Json.JsonSerializer.Deserialize(kwc.Content); - Log.Information($"[PluginsServer] MessageReceived: cmd.Request = {cmd.Request}, expected = {KitX.Shared.CSharp.WebCommand.Infos.CommandRequestInfo.RegisterPlugin}"); - if (cmd.Request == KitX.Shared.CSharp.WebCommand.Infos.CommandRequestInfo.RegisterPlugin) + Log.Information($"[PluginsServer] Processing RegisterPlugin message"); + var body = System.Text.Encoding.UTF8.GetString(cmd.Body.AsSpan(0, cmd.BodyLength).ToArray()); + var pluginInfo = System.Text.Json.JsonSerializer.Deserialize(body); + if (pluginInfo is not null) { - Log.Information($"[PluginsServer] Processing RegisterPlugin message"); - var body = System.Text.Encoding.UTF8.GetString(cmd.Body.AsSpan(0, cmd.BodyLength).ToArray()); - var pluginInfo = System.Text.Json.JsonSerializer.Deserialize(body); - if (pluginInfo is not null) + pluginInfo.Tags ??= new(); + pluginInfo.Tags[nameof(PluginConnection.ConnectionId)] = connectionId; + pluginInfo.Tags["JoinTime"] = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss(FF)"); + connection.PluginInfo = pluginInfo; + Log.Information($"[PluginsServer] Publishing PluginRegistered event for: {pluginInfo.Name}"); + _eventService.Publish(EventNames.PluginRegistered, new PluginRegisteredEventArgs { - pluginInfo.Tags ??= new(); - pluginInfo.Tags[nameof(PluginConnection.ConnectionId)] = connectionId; - pluginInfo.Tags["JoinTime"] = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss(FF)"); - connection.PluginInfo = pluginInfo; - // Publish event via EventService only (removed direct Invoke to eliminate dual-invocation) - Log.Information($"[PluginsServer] Publishing PluginRegistered event for: {pluginInfo.Name}"); - EventService.Instance.Publish(EventNames.PluginRegistered, new PluginRegisteredEventArgs - { - PluginInfo = pluginInfo - }); + PluginInfo = pluginInfo + }); - // After registration, the WebSocket is already open (OnOpen fired earlier - // but PluginInfo was null, so StatusReport was ignored). Explicitly notify - // PluginsManager that this plugin is now Running so the status light updates. - try - { - if (DI.ServiceHost.IsInitialized) - { - var pluginsManager = (Plugin.PluginsManager)DI.ServiceHost.GetRequiredService(); - pluginsManager.OnPluginStatusChanged(pluginInfo.Name, PluginStatus.Running); - } - } - catch (Exception ex) + try + { + if (DI.ServiceHost.IsInitialized) { - Log.Warning(ex, "[PluginsServer] Failed to notify Running status after registration for {PluginName}", - pluginInfo.Name); + var pluginsManager = (Plugin.PluginsManager)DI.ServiceHost.GetRequiredService(); + pluginsManager.OnPluginStatusChanged(pluginInfo.Name, PluginStatus.Running); } } + catch (Exception ex) + { + Log.Warning(ex, "[PluginsServer] Failed to notify Running status after registration for {PluginName}", + pluginInfo.Name); + } } } } - catch (Exception ex) - { - Log.Warning(ex, "[PluginsServer] Error handling plugin message"); - } - - // Also trigger PluginMessageReceived event for components that subscribe directly - Log.Information($"[PluginsServer] Invoking PluginMessageReceived event for connection {connectionId}"); - PluginMessageReceived?.Invoke(this, new PluginMessageReceivedEventArgs - { - ConnectionId = connectionId, - Message = message - }); - }; + } + catch (Exception ex) + { + Log.Warning(ex, "[PluginsServer] Error handling plugin message"); + } - // Forward PluginResponse events from PluginConnection to PluginsServer.PluginResponse - connection.PluginResponse += (sender, args) => + Log.Information($"[PluginsServer] Invoking PluginMessageReceived event for connection {connectionId}"); + PluginMessageReceived?.Invoke(this, new PluginMessageReceivedEventArgs { - Log.Information($"[PluginsServer] Publishing PluginResponse event, RequestId: {args.RequestId}"); - EventService.Instance.Publish(EventNames.PluginResponse, args); - }; - - // Forward StatusReport events from PluginConnection to PluginsManager. - // This enables the status light in Dashboard to update when plugin status changes - // (e.g., from Pending to Running, or from Running to Errored). - connection.StatusReport += (sender, args) => + ConnectionId = connectionId, + Message = message + }); + }; + + connection.PluginResponse += (sender, args) => + { + Log.Information($"[PluginsServer] Publishing PluginResponse event, RequestId: {args.RequestId}"); + _eventService.Publish(EventNames.PluginResponse, args); + }; + + connection.StatusReport += (sender, args) => + { + try { - try + var conn = sender as PluginConnection ?? connection; + var pluginName = conn.PluginInfo?.Name; + if (pluginName is null) { - var conn = sender as PluginConnection ?? connection; - var pluginName = conn.PluginInfo?.Name; - if (pluginName is null) - { - Log.Debug("[PluginsServer] StatusReport received but plugin not yet registered, ignoring (ConnectionId: {ConnectionId})", args.ConnectionId); - return; - } + Log.Debug("[PluginsServer] StatusReport received but plugin not yet registered, ignoring (ConnectionId: {ConnectionId})", args.ConnectionId); + return; + } - var newStatus = args.Status switch - { - "Running" => PluginStatus.Running, - "Pending" => PluginStatus.Stopped, - "Errored" => PluginStatus.Error, - _ => PluginStatus.Unknown - }; + var newStatus = args.Status switch + { + "Running" => PluginStatus.Running, + "Pending" => PluginStatus.Stopped, + "Errored" => PluginStatus.Error, + _ => PluginStatus.Unknown + }; - Log.Information("[PluginsServer] Plugin '{PluginName}' status changed to {Status} (ConnectionId: {ConnectionId})", - pluginName, newStatus, args.ConnectionId); + Log.Information("[PluginsServer] Plugin '{PluginName}' status changed to {Status} (ConnectionId: {ConnectionId})", + pluginName, newStatus, args.ConnectionId); - // Update PluginsManager internal state and notify UI - if (DI.ServiceHost.IsInitialized) - { - var pluginsManager = (Plugin.PluginsManager)DI.ServiceHost.GetRequiredService(); - pluginsManager.OnPluginStatusChanged(pluginName, newStatus); - } - else - { - Log.Error("[PluginsServer] Cannot forward status change: ServiceHost not initialized"); - } + if (DI.ServiceHost.IsInitialized) + { + var pluginsManager = (Plugin.PluginsManager)DI.ServiceHost.GetRequiredService(); + pluginsManager.OnPluginStatusChanged(pluginName, newStatus); } - catch (Exception ex) + else { - Log.Warning(ex, "[PluginsServer] Error forwarding StatusReport to PluginsManager"); + Log.Error("[PluginsServer] Cannot forward status change: ServiceHost not initialized"); } - }; - - connection.Initialize(); - - // Publish event via EventService only (removed direct Invoke to eliminate dual-invocation) - EventService.Instance.Publish(EventNames.PluginConnected, new PluginConnectedEventArgs + } + catch (Exception ex) { - ConnectionId = connectionId - }); - }); + Log.Warning(ex, "[PluginsServer] Error forwarding StatusReport to PluginsManager"); + } + }; - Port = _server!.Port; - serverStarted = true; + connection.Initialize(); - // Update ConstantTable with the actual port - ConstantTable.PluginsServerPort = Port ?? 0; + _eventService.Publish(EventNames.PluginConnected, new PluginConnectedEventArgs + { + ConnectionId = connectionId + }); + }); - Log.Information($"[PluginsServer] PluginsServer started on port {Port}"); + Port = _server!.Port; + serverStarted = true; - // Publish port changed event via EventService only (removed direct PortChanged event to avoid potential recursion) - EventService.Instance.Publish(EventNames.PluginsServerPortChanged, new PortChangedEventArgs { Port = Port ?? 0 }); - } - catch (System.Net.Sockets.SocketException ex) - { - Log.Warning(ex, $"[PluginsServer] Socket error on port {currentPort}: {ex.Message} (attempt {retryCount + 1}/{maxRetries})"); - _server?.Dispose(); - _server = null; + ConstantTable.PluginsServerPort = Port ?? 0; - // If using a fixed port, don't retry - if (ConstantTable.PluginsServerPort > 0) - break; - } - catch (Exception ex) - { - Log.Error(ex, $"[PluginsServer] Unexpected error starting PluginsServer on port {currentPort}"); - _server?.Dispose(); - _server = null; + Log.Information($"[PluginsServer] PluginsServer started on port {Port}"); - // If using a fixed port, don't retry - if (ConstantTable.PluginsServerPort > 0) - break; - } + _eventService.Publish(EventNames.PluginsServerPortChanged, new PortChangedEventArgs { Port = Port ?? 0 }); } - - if (!serverStarted) + catch (System.Net.Sockets.SocketException ex) { - Log.Error($"Failed to start PluginsServer after {maxRetries} attempts"); - SetErrored(null, nameof(PluginsServer)); - return this; + Log.Warning(ex, $"[PluginsServer] Socket error on port {currentPort}: {ex.Message}"); + _server?.Dispose(); + _server = null; + } + catch (Exception ex) + { + Log.Error(ex, $"[PluginsServer] Unexpected error starting PluginsServer on port {currentPort}"); + _server?.Dispose(); + _server = null; } - - SetRunning(); - - return this; } /// @@ -364,7 +347,7 @@ public IPluginServer Run() /// /// The connection ID /// The plugin connection or null if not found - public IPluginConnection? FindConnection(string connectionId) => + public KitX.Core.Contract.Plugin.IPluginConnection? FindConnection(string connectionId) => _connections.FirstOrDefault(x => x.ConnectionId?.Equals(connectionId) ?? false); /// @@ -384,7 +367,7 @@ public IPluginServer Run() /// /// The plugin info /// The plugin connection or null if not found - public IPluginConnection? FindConnection(PluginInfo pluginInfo) + public KitX.Core.Contract.Plugin.IPluginConnection? FindConnection(PluginInfo pluginInfo) { return _connections.FirstOrDefault(x => x.PluginInfo is not null && x.PluginInfo.Equals(pluginInfo)); } diff --git a/KitX Clients/KitX Core/KitX.Core/Event/EventService.cs b/KitX Clients/KitX Core/KitX.Core/Event/EventService.cs index 9549a22c..09c0ba2b 100644 --- a/KitX Clients/KitX Core/KitX.Core/Event/EventService.cs +++ b/KitX Clients/KitX Core/KitX.Core/Event/EventService.cs @@ -12,22 +12,6 @@ namespace KitX.Core.Event; /// public class EventService : IEventService { - /// - /// Gets the singleton instance (resolves from ServiceHost when available). - /// Internal code should use constructor injection instead. - /// - public static EventService Instance - { - get - { - if (ServiceHost.IsInitialized) - return (EventService)ServiceHost.GetRequiredService(); - Log.Error("[EventService] Instance: ServiceHost not initialized! Returning orphan instance — " + - "this indicates a DI initialization order bug. Use ServiceHost/constructor injection instead."); - return new EventService(); - } - } - private readonly Dictionary>> _eventHandlers = new(); /// diff --git a/KitX Clients/KitX Core/KitX.Core/Plugin/PluginsManager.cs b/KitX Clients/KitX Core/KitX.Core/Plugin/PluginsManager.cs index fce2e34c..52c5b625 100644 --- a/KitX Clients/KitX Core/KitX.Core/Plugin/PluginsManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/Plugin/PluginsManager.cs @@ -724,7 +724,7 @@ public async Task StartPluginAsync(Guid pluginId) var startTime = DateTime.UtcNow; while (DateTime.UtcNow - startTime < DefaultStartTimeout) { - var connection = pluginsServer.Connections + var connection = ((Contract.Plugin.IPluginServer)pluginsServer).Connections .FirstOrDefault(c => c.PluginInfo?.Name == pluginName); if (connection != null) { @@ -1368,8 +1368,8 @@ private static (string fileName, string arguments) BuildProcessStartInfo( { try { - return pluginsServer.Connections - .FirstOrDefault(c => c.PluginInfo?.Name == pluginName) as IPluginConnection; + return ((Contract.Plugin.IPluginServer)pluginsServer).Connections + .FirstOrDefault(c => c.PluginInfo?.Name == pluginName); } catch (Exception ex) { @@ -1387,7 +1387,7 @@ private static async Task WaitForPluginDisconnection(PluginsServer plugins var startTime = DateTime.UtcNow; while (DateTime.UtcNow - startTime < timeout) { - var connection = pluginsServer.Connections + var connection = ((Contract.Plugin.IPluginServer)pluginsServer).Connections .FirstOrDefault(c => c.PluginInfo?.Name == pluginName); if (connection == null) diff --git a/KitX Clients/KitX Core/KitX.Core/Security/SecurityManager.cs b/KitX Clients/KitX Core/KitX.Core/Security/SecurityManager.cs index 882d0d78..91d6c2c5 100644 --- a/KitX Clients/KitX Core/KitX.Core/Security/SecurityManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/Security/SecurityManager.cs @@ -4,7 +4,9 @@ using System.Security.Cryptography; using System.Text; using System.Text.Json; +using KitX.Core.Contract.Configuration; using KitX.Core.Contract.Security; +using KitX.Core.Contract.Device; using KitX.Core.Configuration; using KitX.Core.Device; using KitX.Shared.CSharp.Device; @@ -19,40 +21,24 @@ namespace KitX.Core.Security; /// public class SecurityManager : IDeviceKeyService, IEncryptionService { - /// - /// Gets the singleton instance (resolves from ServiceHost when available). - /// Internal code should use constructor injection instead. - /// - public static SecurityManager Instance - { - get - { - if (ServiceHost.IsInitialized) - return (SecurityManager)ServiceHost.GetRequiredService(); - Log.Error("[SecurityManager] Instance: ServiceHost not initialized! Returning orphan instance — " + - "this indicates a DI initialization order bug. Use ServiceHost/constructor injection instead."); - return new SecurityManager(); - } - } - private RSA? _rsaInstance; private DeviceKey? _localDeviceKey; /// - /// Reference to ConfigManager instance + /// Reference to IConfigService instance /// - private readonly ConfigManager _configManager; + private readonly IConfigService _configService; /// /// Reference to DevicesDiscoveryServer instance /// - private readonly DevicesDiscoveryServer? _devicesDiscoveryServer; + private readonly IDeviceDiscoveryService? _devicesDiscoveryService; /// /// Gets typed SecurityConfig for direct property access /// - private SecurityConfig? TypedSecurityConfig => _configManager.TypedSecurityConfig; + private SecurityConfig? TypedSecurityConfig => _configService.SecurityConfig as SecurityConfig; /// /// Gets the local device key @@ -63,22 +49,15 @@ public DeviceKey? LocalDeviceKey set => _localDeviceKey = value; } - /// - /// Creates a new security manager - /// - public SecurityManager() : this(ConfigManager.Instance, null) - { - } - /// /// Creates a new security manager with dependencies /// - /// Configuration manager - /// Devices discovery server (optional for backward compatibility) - public SecurityManager(ConfigManager configManager, DevicesDiscoveryServer? devicesDiscoveryServer) + /// Configuration service + /// Device discovery service (optional for backward compatibility) + public SecurityManager(IConfigService configService, IDeviceDiscoveryService? deviceDiscoveryService) { - _configManager = configManager ?? ConfigManager.Instance; - _devicesDiscoveryServer = devicesDiscoveryServer; + _configService = configService; + _devicesDiscoveryService = deviceDiscoveryService; Initialize(); } @@ -90,8 +69,8 @@ private void Initialize() // Create RSA instance _rsaInstance = RSA.Create(2048); - // Get current device info from DevicesDiscoveryServer (same as legacy architecture) - var defaultDeviceInfo = _devicesDiscoveryServer?.DefaultDeviceInfo; + // Get current device info from DeviceDiscoveryService (same as legacy architecture) + var defaultDeviceInfo = _devicesDiscoveryService?.DefaultDeviceInfo; var currentDevice = defaultDeviceInfo?.Device ?? new DeviceLocator { DeviceName = Environment.MachineName, @@ -102,7 +81,7 @@ private void Initialize() currentDevice.DeviceName, currentDevice.MacAddress); // Get typed SecurityConfig - var typedSecurityConfig = _configManager.SecurityConfig as SecurityConfig; + var typedSecurityConfig = TypedSecurityConfig; // Try to find existing device key in SecurityConfig var existingKey = typedSecurityConfig?.DeviceKeys @@ -176,10 +155,10 @@ private void GenerateLocalDeviceKey() AddedAt = DateTime.Now }; - _configManager?.TypedSecurityConfig?.DeviceKeys.Add(deviceKeyImpl); - _configManager?.SaveAll(); + _configService.SecurityConfig.DeviceKeys.Add(deviceKeyImpl); + _configService.SaveAll(); - Log.Information($"Generated and saved new local device key. Keys count: {_configManager?.TypedSecurityConfig?.DeviceKeys.Count}"); + Log.Information($"Generated and saved new local device key. Keys count: {_configService.SecurityConfig.DeviceKeys.Count}"); } /// @@ -188,7 +167,7 @@ private void GenerateLocalDeviceKey() /// List of device keys public IReadOnlyList GetDeviceKeys() { - var keys = _configManager?.TypedSecurityConfig?.DeviceKeys; + var keys = _configService.SecurityConfig.DeviceKeys; if (keys == null) return new List(); @@ -218,8 +197,8 @@ public bool AddDeviceKey(string macAddress, string deviceName, string publicKey) AddedAt = DateTime.Now }; - _configManager?.TypedSecurityConfig?.DeviceKeys.Add(deviceKey); - _configManager?.SaveAll(); + _configService.SecurityConfig.DeviceKeys.Add(deviceKey); + _configService.SaveAll(); Log.Information($"Added device key for {deviceName} ({macAddress})"); @@ -241,7 +220,7 @@ public bool RemoveDeviceKey(string macAddress) { try { - var typedSecurityConfig = _configManager?.SecurityConfig as SecurityConfig; + var typedSecurityConfig = TypedSecurityConfig; var keysToRemove = typedSecurityConfig?.DeviceKeys .Where(x => IsSameDevice(x.Device.MacAddress, macAddress)) .ToList(); @@ -250,9 +229,9 @@ public bool RemoveDeviceKey(string macAddress) { foreach (var key in keysToRemove) { - _configManager?.TypedSecurityConfig?.DeviceKeys.Remove(key); + _configService.SecurityConfig.DeviceKeys.Remove(key); } - _configManager?.SaveAll(); + _configService.SaveAll(); } Log.Information($"Removed device key for {macAddress}"); @@ -457,14 +436,16 @@ public string GetSHA1(string data) /// The device key if found, otherwise null public DeviceKey? SearchDeviceKey(DeviceLocator locator) { - var existing = _configManager?.TypedSecurityConfig?.DeviceKeys + var existing = _configService.SecurityConfig.DeviceKeys .FirstOrDefault(x => x.Device.IsSameDevice(locator)); if (existing == null) return null; + // Cast to DeviceKeyImpl to access RsaPrivateKeyPem property + var deviceKeyImpl = existing as Configuration.DeviceKeyImpl; return new DeviceKey { Device = existing.Device, RsaPublicKeyPem = existing.RsaPublicKeyPem, - RsaPrivateKeyPem = existing.RsaPrivateKeyPem + RsaPrivateKeyPem = deviceKeyImpl?.RsaPrivateKeyPem }; } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/TryGetDeviceFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/TryGetDeviceFunction.cs index ec76a73b..e27eec4c 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/TryGetDeviceFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/TryGetDeviceFunction.cs @@ -4,6 +4,7 @@ using System.Reflection; using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; +using KitX.Core.Contract.Device; using KitX.Core.Device; using KitX.Core.Workflow.Blueprint; using KitX.Core.Workflow.Blueprint.CFG; @@ -120,7 +121,7 @@ public partial class BlockScriptExecutionGlobals { /// /// 根据设备名称查找已连接设备的 DeviceInfo。 - /// 内部使用 DevicesServer._signedDeviceTokens 查找匹配的 DeviceLocator。 + /// 使用 IDeviceServer.GetSignedInDevices() 查找匹配的 DeviceLocator。 /// public object? TryGetDevice(string deviceName) { @@ -132,45 +133,28 @@ public partial class BlockScriptExecutionGlobals try { - var tokensField = typeof(DevicesServer) - .GetField("_signedDeviceTokens", - BindingFlags.NonPublic | BindingFlags.Instance); - - if (tokensField == null) + if (!DI.ServiceHost.IsInitialized) { - Log.Warning("[BlockScriptGlobals] TryGetDevice: _signedDeviceTokens field not found"); + Log.Warning("[BlockScriptGlobals] TryGetDevice: ServiceHost not initialized"); return null; } - var tokens = tokensField.GetValue(DevicesServer.Instance) as System.Collections.IDictionary; - if (tokens == null) - { - Log.Warning("[BlockScriptGlobals] TryGetDevice: _signedDeviceTokens is null"); - return null; - } + var deviceServer = DI.ServiceHost.GetRequiredService(); + var discoveryService = DI.ServiceHost.GetRequiredService(); - foreach (System.Collections.DictionaryEntry entry in tokens) + var signedInDevices = deviceServer.GetSignedInDevices(); + + foreach (var locator in signedInDevices) { - if (entry.Key is DeviceLocator locator && - locator.DeviceName.Equals(deviceName, StringComparison.OrdinalIgnoreCase)) + if (locator.DeviceName.Equals(deviceName, StringComparison.OrdinalIgnoreCase)) { Log.Information("[BlockScriptGlobals] TryGetDevice: found device {DeviceName} (IPv4={IPv4})", locator.DeviceName, locator.IPv4); - var discoveryInstance = typeof(DevicesDiscoveryServer) - .GetProperty("Instance", - BindingFlags.NonPublic | BindingFlags.Static)? - .GetValue(null); - - if (discoveryInstance != null) + var defaultInfo = discoveryService.DefaultDeviceInfo; + if (defaultInfo != null && defaultInfo.Device.IsSameDevice(locator)) { - var defaultInfoProperty = discoveryInstance.GetType() - .GetProperty("DefaultDeviceInfo"); - if (defaultInfoProperty?.GetValue(discoveryInstance) is DeviceInfo info && - info.Device.IsSameDevice(locator)) - { - return info; - } + return defaultInfo; } return new DeviceInfo diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/RealPluginManager.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/RealPluginManager.cs index 353106a8..ae594e9f 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/RealPluginManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/RealPluginManager.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Concurrent; using System.Linq; using System.Net.Http; @@ -9,10 +9,11 @@ using KitX.Core.Contract.Workflow; using KitX.Core.Contract.Plugin; using KitX.Core.Contract.Device; +using KitX.Core.Contract.Event; using KitX.Core.Device; using KitX.Core.Contract.Plugin.Events; -using KitX.Core.Device.Events; using KitX.Core.Event; +using KitX.Core.DI; using KitX.Shared.CSharp.Device; using KitX.Shared.CSharp.Plugin; using KitX.Shared.CSharp.WebCommand; @@ -22,6 +23,7 @@ using KcsPluginCallInfo = Kscript.CSharp.Parser.Models.PluginCallInfo; using KcsIPluginManager = Kscript.CSharp.Parser.Core.IPluginManager; +using PluginMessageReceivedEventArgs = KitX.Core.Contract.Plugin.Events.PluginMessageReceivedEventArgs; namespace KitX.Core.Workflow; @@ -32,8 +34,11 @@ namespace KitX.Core.Workflow; /// public class RealPluginManager : IPluginManager, KcsIPluginManager { - private readonly PluginsServer _pluginsServer; + private readonly IPluginServer _pluginServer; + private readonly IDeviceServer _deviceServer; private readonly IDeviceHttpClient _deviceHttpClient; + private readonly IEventService _eventService; + private readonly IDeviceDiscoveryService _deviceDiscoveryService; private readonly JsonSerializerOptions _serializerOptions = new() { WriteIndented = true, @@ -47,41 +52,65 @@ public class RealPluginManager : IPluginManager, KcsIPluginManager private readonly ConcurrentDictionary> _pendingResponses = new(); /// - /// 无参构造函数(供 KScript.Parser 动态创建实例使用) + /// 构造函数(仅需 IPluginServer,其他依赖从 ServiceHost 解析) /// - public RealPluginManager() : this(PluginsServer.Instance, new DeviceHttpClient()) + /// 插件服务器实例 + public RealPluginManager(IPluginServer pluginServer) + : this(pluginServer, + ServiceHost.IsInitialized ? ServiceHost.GetRequiredService() : new KitX.Core.Event.EventService(), + ServiceHost.IsInitialized ? ServiceHost.GetRequiredService() : null!, + ServiceHost.IsInitialized ? ServiceHost.GetRequiredService() : null!, + new DeviceHttpClient()) { } /// /// 构造函数 /// - /// 插件服务器实例 - public RealPluginManager(PluginsServer pluginsServer) - : this(pluginsServer, new DeviceHttpClient()) + /// 插件服务器实例 + /// 事件服务实例 + /// 设备发现服务实例 + public RealPluginManager(IPluginServer pluginServer, IEventService eventService, IDeviceDiscoveryService deviceDiscoveryService) + : this(pluginServer, eventService, deviceDiscoveryService, null!, new DeviceHttpClient()) { } /// /// 构造函数 /// - /// 插件服务器实例 + /// 插件服务器实例 + /// 事件服务实例 + /// 设备发现服务实例 /// HTTP 客户端,用于跨设备调用 - public RealPluginManager(PluginsServer pluginsServer, IDeviceHttpClient deviceHttpClient) + public RealPluginManager(IPluginServer pluginServer, IEventService eventService, IDeviceDiscoveryService deviceDiscoveryService, IDeviceHttpClient deviceHttpClient) + : this(pluginServer, eventService, deviceDiscoveryService, null!, deviceHttpClient) { - _pluginsServer = pluginsServer; + } + + /// + /// 构造函数 + /// + /// 插件服务器实例 + /// 事件服务实例 + /// 设备发现服务实例 + /// 设备服务器实例 + /// HTTP 客户端,用于跨设备调用 + public RealPluginManager(IPluginServer pluginServer, IEventService eventService, IDeviceDiscoveryService deviceDiscoveryService, IDeviceServer deviceServer, IDeviceHttpClient deviceHttpClient) + { + _pluginServer = pluginServer ?? throw new ArgumentNullException(nameof(pluginServer)); + _eventService = eventService ?? throw new ArgumentNullException(nameof(eventService)); + _deviceDiscoveryService = deviceDiscoveryService ?? throw new ArgumentNullException(nameof(deviceDiscoveryService)); + _deviceServer = deviceServer; _deviceHttpClient = deviceHttpClient ?? new DeviceHttpClient(); + Log.Information("[RealPluginManager] Constructor. This HashCode: {ThisHashCode}, PluginServer HashCode: {PluginServerHashCode}", + GetHashCode(), _pluginServer.GetHashCode()); + // 订阅插件消息接收事件以处理响应 - _pluginsServer.PluginMessageReceived += OnPluginMessageReceived; + _pluginServer.PluginMessageReceived += OnPluginMessageReceived; // 订阅插件响应事件(当插件返回带RequestId的响应时触发) - // IMPORTANT: Subscribe to EventService.PluginResponse instead of PluginsServer.PluginResponse, - // because PluginsServer forwards PluginConnection.PluginResponse to EventService, not to PluginsServer.PluginResponse - if (EventService.Instance != null) - { - EventService.Instance.Subscribe(EventNames.PluginResponse, (sender, args) => OnPluginResponse(this, args)); - } + _eventService.Subscribe(EventNames.PluginResponse, (sender, args) => OnPluginResponse(this, args)); } /// @@ -220,7 +249,7 @@ private static PluginCallInfo ConvertFromKcs(KcsPluginCallInfo kcs) /// /// 自动调用插件方法:根据函数声明的返回类型自动选择调用策略。 /// - void 返回类型 → fire-and-forget (Call),不等待响应 - /// - 非 void 返回类型 → 类型化同步等待 (Call<T>),返回具体类型的结果 + /// - 非 void 返回类型 → 类型化同步等待 (Call),返回具体类型的结果 /// 调用方无需关心分发逻辑,所有判断由 Manager 内部完成。 /// /// 插件调用信息 @@ -260,8 +289,8 @@ private async Task CallAsync(PluginCallInfo callInfo) if (connection is null) { Log.Error($"[RealPluginManager] Plugin connection not found: {callInfo.PluginName}"); - Log.Information($"[RealPluginManager] Available connections: {_pluginsServer.Connections.Count}"); - foreach (var conn in _pluginsServer.Connections) + Log.Information($"[RealPluginManager] Available connections: {_pluginServer.Connections.Count}"); + foreach (var conn in _pluginServer.Connections) { Log.Information($"[RealPluginManager] Connection: {conn.ConnectionId}, Plugin: {conn.PluginInfo?.Name}"); } @@ -527,7 +556,7 @@ public bool IsPluginExists(string pluginName) Log.Information($"[RealPluginManager] IsPluginExists({pluginName}): {exists}"); if (!exists) { - foreach (var conn in _pluginsServer.Connections) + foreach (var conn in _pluginServer.Connections) { Log.Information($"[RealPluginManager] Available connection: {conn.ConnectionId}, Plugin: {conn.PluginInfo?.Name}"); } @@ -552,17 +581,12 @@ public bool IsMethodExists(string pluginName, string methodName) /// private IPluginConnection? FindPluginConnection(string pluginName) { - Log.Information($"[RealPluginManager] FindPluginConnection: _pluginsServer HashCode={_pluginsServer.GetHashCode()}, Connections Count={_pluginsServer.Connections.Count}"); - Log.Information($"[RealPluginManager] FindPluginConnection: PluginsServer.Instance HashCode={PluginsServer.Instance.GetHashCode()}"); - if (_pluginsServer.GetHashCode() != PluginsServer.Instance.GetHashCode()) - { - Log.Warning($"[RealPluginManager] WARNING: _pluginsServer ({_pluginsServer.GetHashCode()}) != PluginsServer.Instance ({PluginsServer.Instance.GetHashCode()})!"); - } - foreach (var c in _pluginsServer.Connections) + Log.Information($"[RealPluginManager] FindPluginConnection: _pluginServer HashCode={_pluginServer.GetHashCode()}, Connections Count={_pluginServer.Connections.Count}"); + foreach (var c in _pluginServer.Connections) { Log.Information($"[RealPluginManager] FindPluginConnection: connection PluginInfo.Name={c.PluginInfo?.Name}"); } - var result = _pluginsServer.Connections + var result = _pluginServer.Connections .FirstOrDefault(c => c.PluginInfo?.Name == pluginName); Log.Information($"[RealPluginManager] FindPluginConnection result: {result?.GetHashCode()}"); return result; @@ -578,31 +602,14 @@ public bool IsMethodExists(string pluginName, string methodName) try { - // Access DevicesServer's signed tokens dictionary directly - // DevicesServer._signedDeviceTokens is Dictionary - // We need to find the token where the DeviceLocator matches deviceInfo.Device - var tokensField = typeof(DevicesServer) - .GetField("_signedDeviceTokens", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - - if (tokensField == null) + // Use IDeviceServer.GetDeviceToken() to get the token + if (_deviceServer != null) { - Log.Warning("[RealPluginManager] Could not access _signedDeviceTokens field"); - return null; + return _deviceServer.GetDeviceToken(deviceInfo.Device); } - var tokens = tokensField.GetValue(DevicesServer.Instance) as System.Collections.IDictionary; - if (tokens == null) - return null; - - foreach (System.Collections.DictionaryEntry entry in tokens) - { - if (entry.Key is KitX.Shared.CSharp.Device.DeviceLocator locator && - locator.IsSameDevice(deviceInfo.Device)) - { - return entry.Value as string; - } - } + Log.Warning("[RealPluginManager] DeviceServer is not available"); + return null; } catch (Exception ex) { @@ -622,38 +629,29 @@ public bool IsMethodExists(string pluginName, string methodName) try { - // Use DevicesDiscoveryServer.DefaultDeviceInfo as reference for local device - // Try to find a matching device from the signed tokens - var tokensField = typeof(DevicesServer) - .GetField("_signedDeviceTokens", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - - if (tokensField == null) - return null; - - var tokens = tokensField.GetValue(DevicesServer.Instance) as System.Collections.IDictionary; - if (tokens == null) - return null; - - foreach (System.Collections.DictionaryEntry entry in tokens) + // Use IDeviceServer.GetSignedInDevices() to find matching device + if (_deviceServer != null) { - if (entry.Key is KitX.Shared.CSharp.Device.DeviceLocator locator && - locator.DeviceName.Equals(deviceName, StringComparison.OrdinalIgnoreCase)) + var signedInDevices = _deviceServer.GetSignedInDevices(); + foreach (var locator in signedInDevices) { - // Found the locator by name — now we need DeviceInfo which has the full info - // We can construct a basic DeviceInfo from the locator + DevicesDiscoveryServer data - var defaultInfo = KitX.Core.Device.DevicesDiscoveryServer.Instance?.DefaultDeviceInfo; - if (defaultInfo != null && defaultInfo.Device.IsSameDevice(locator)) + if (locator.DeviceName.Equals(deviceName, StringComparison.OrdinalIgnoreCase)) { - return defaultInfo; + // Found the locator by name — now we need DeviceInfo which has the full info + // We can construct a basic DeviceInfo from the locator + DevicesDiscoveryServer data + var defaultInfo = _deviceDiscoveryService.DefaultDeviceInfo; + if (defaultInfo != null && defaultInfo.Device.IsSameDevice(locator)) + { + return defaultInfo; + } + + // Fallback: construct from locator alone (missing some fields) + return new DeviceInfo + { + Device = locator, + SendTime = DateTime.UtcNow + }; } - - // Fallback: construct from locator alone (missing some fields) - return new DeviceInfo - { - Device = locator, - SendTime = DateTime.UtcNow - }; } } } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/TriggerManager.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/TriggerManager.cs index 165331ef..48b68034 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/TriggerManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/TriggerManager.cs @@ -5,13 +5,17 @@ using System.Threading.Tasks; using KitX.Core.Contract.Workflow; using KitX.Core.Contract.Device; +using KitX.Core.Contract.Event; +using KitX.Core.Contract.Plugin; +using KitX.Core.Contract.Plugin.Events; using KitX.Core.Device; -using KitX.Core.Device.Events; using KitX.Shared.CSharp.WebCommand; using KitX.Shared.CSharp.WebCommand.Infos; using Serilog; using KitX.Core.DI; +using PluginMessageReceivedEventArgs = KitX.Core.Contract.Plugin.Events.PluginMessageReceivedEventArgs; + namespace KitX.Core.Workflow; /// @@ -20,23 +24,7 @@ namespace KitX.Core.Workflow; /// public class TriggerManager { - /// - /// Gets the singleton instance (resolves from ServiceHost when available). - /// Internal code should use constructor injection instead. - /// - public static TriggerManager Instance - { - get - { - if (ServiceHost.IsInitialized) - return ServiceHost.GetRequiredService(); - Log.Error("[TriggerManager] Instance: ServiceHost not initialized! Returning orphan instance — " + - "this indicates a DI initialization order bug. Use ServiceHost/constructor injection instead."); - return new TriggerManager(); - } - } - - private readonly PluginsServer _pluginsServer; + private readonly IPluginServer _pluginServer; private readonly JsonSerializerOptions _serializerOptions = new() { WriteIndented = true, @@ -59,8 +47,10 @@ public static TriggerManager Instance /// public TriggerManager() { - _pluginsServer = PluginsServer.Instance; - _pluginsServer.PluginMessageReceived += OnPluginMessageReceived; + _pluginServer = ServiceHost.IsInitialized + ? ServiceHost.GetRequiredService() + : new KitX.Core.Device.PluginsServer(new KitX.Core.Event.EventService()); + _pluginServer.PluginMessageReceived += OnPluginMessageReceived; Log.Information("[TriggerManager] Initialized and subscribed to PluginMessageReceived"); } @@ -173,7 +163,7 @@ private void OnPluginMessageReceived(object? sender, PluginMessageReceivedEventA // 查找发送此消息的插件名称 var connectionId = e.ConnectionId; - var connection = _pluginsServer.Connections + var connection = _pluginServer.Connections .FirstOrDefault(c => c.ConnectionId == connectionId); var pluginName = connection?.PluginInfo?.Name ?? "Unknown"; @@ -199,7 +189,7 @@ private void OnPluginMessageReceived(object? sender, PluginMessageReceivedEventA _ = System.Threading.Tasks.Task.Run(async () => { bool success = await ServiceHost.GetRequiredService().RunWorkflowAsync(workflowId); - KitX.Core.Event.EventService.Instance.Publish( + ServiceHost.GetRequiredService().Publish( KitX.Core.Event.EventNames.WorkflowExecutionResult, new KitX.Core.Contract.Event.WorkflowExecutionResultEventArgs( workflowId, success, diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowPluginService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowPluginService.cs index ff8acc60..b29ecc6a 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowPluginService.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowPluginService.cs @@ -2,7 +2,9 @@ using System.Collections.Generic; using System.Linq; using KitX.Core.Contract.Workflow; +using KitX.Core.Contract.Plugin; using KitX.Core.Device; +using KitX.Core.DI; using KitX.Shared.CSharp.Plugin; using Microsoft.CodeAnalysis.CSharp; using Serilog; @@ -33,8 +35,10 @@ public void InitializePluginManager() try { - var pluginsServer = PluginsServer.Instance; - var realPluginManager = new RealPluginManager(pluginsServer); + var pluginServer = ServiceHost.IsInitialized + ? ServiceHost.GetRequiredService() + : new KitX.Core.Device.PluginsServer(new KitX.Core.Event.EventService()); + var realPluginManager = new RealPluginManager(pluginServer); Kscript.CSharp.Parser.Parser.SetPluginManager(realPluginManager); diff --git a/KitX Clients/KitX Dashboard b/KitX Clients/KitX Dashboard index 409e383a..e5d7e959 160000 --- a/KitX Clients/KitX Dashboard +++ b/KitX Clients/KitX Dashboard @@ -1 +1 @@ -Subproject commit 409e383adc52213eb69a79a951dce5cfc0e64a85 +Subproject commit e5d7e959e5c303ceafff3b7c9a995bca39204b31 diff --git a/KitX Standard b/KitX Standard index 6cf48eb5..f8509d2b 160000 --- a/KitX Standard +++ b/KitX Standard @@ -1 +1 @@ -Subproject commit 6cf48eb598d68030a9e3013bd37119e9e1a9c6f8 +Subproject commit f8509d2b2531958dfd2307a04ec32e38b8f0f6c6 From eaa652c331737b0fa4efc767e89f47b06f25db91 Mon Sep 17 00:00:00 2001 From: StarInk Date: Sat, 2 May 2026 23:59:34 +0200 Subject: [PATCH 063/110] =?UTF-8?q?=F0=9F=94=A7=20Fix(PluginImport):=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=AF=BC=E5=85=A5=E9=80=BB=E8=BE=91=E4=B8=8E?= =?UTF-8?q?=E6=97=A7=E7=89=88=E4=B8=80=E8=87=B4=EF=BC=9A=E4=BB=8Ekxp?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E5=A4=B4=E4=B8=AD=E8=8E=B7=E5=8F=96=E4=BF=A1?= =?UTF-8?q?=E6=81=AF=E8=80=8C=E4=B8=8D=E6=98=AF=E4=BB=8E=E8=A7=A3=E5=8E=8B?= =?UTF-8?q?=E5=87=BA=E7=9A=84=E6=96=87=E4=BB=B6=E4=B8=AD=E8=8E=B7=E5=8F=96?= =?UTF-8?q?=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../KitX.Core/Plugin/PluginsManager.cs | 220 +++++++++--------- 1 file changed, 104 insertions(+), 116 deletions(-) diff --git a/KitX Clients/KitX Core/KitX.Core/Plugin/PluginsManager.cs b/KitX Clients/KitX Core/KitX.Core/Plugin/PluginsManager.cs index 52c5b625..4b77dfd1 100644 --- a/KitX Clients/KitX Core/KitX.Core/Plugin/PluginsManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/Plugin/PluginsManager.cs @@ -257,15 +257,44 @@ public async Task ImportPluginAsync(string kxpFilePath) try { var decoder = new FileFormats.CSharp.ExtensionsPackage.Decoder(kxpFilePath); + + // Read structs from KXP binary header (preferred path) + var (loaderStructHeader, pluginStructHeader) = decoder.GetLoaderAndPluginInfo(); + decoder.Decode(pluginDir); Log.Information($"Decoded KXP file to: {pluginDir}"); + + // Parse LoaderStruct from header, fall back to extracted file + LoaderInfo? loaderInfo = ParseLoaderStructFromJson(loaderStructHeader); + if (loaderInfo is null) + { + var loaderStructPath = Path.Combine(pluginDir, "LoaderStruct.json"); + if (File.Exists(loaderStructPath)) + { + var loaderStructJson = await File.ReadAllTextAsync(loaderStructPath); + loaderInfo = ParseLoaderStructFromJson(loaderStructJson); + } + } + + // Parse PluginStruct from header, fall back to extracted file + PluginInfo? pluginInfo = ParsePluginStructFromJson(pluginStructHeader); + if (pluginInfo is null) + { + var pluginStructPath = Path.Combine(pluginDir, "PluginStruct.json"); + if (File.Exists(pluginStructPath)) + { + var pluginStructJson = await File.ReadAllTextAsync(pluginStructPath); + pluginInfo = ParsePluginStructFromJson(pluginStructJson); + } + } + + return await FinalizeImport(pluginDir, kxpFilePath, loaderInfo, pluginInfo); } catch (Exception ex) { Log.Warning(ex, "Failed to decode KXP file, trying as direct files..."); // If KXP decode fails, assume it's a directory with files already extracted - // Just copy the source directory contents var sourceDir = Path.GetDirectoryName(kxpFilePath); if (sourceDir != null && Directory.Exists(sourceDir)) { @@ -278,140 +307,100 @@ public async Task ImportPluginAsync(string kxpFilePath) } } } + + return await FinalizeImport(pluginDir, kxpFilePath, null, null); } + } + catch (Exception ex) + { + Log.Error(ex, $"In {location}: Error importing plugin {kxpFilePath}: {ex.Message}"); + return false; + } + } - // Look for LoaderStruct.json and PluginStruct.json in the extracted files - var loaderStructPath = Path.Combine(pluginDir, "LoaderStruct.json"); - var pluginStructPath = Path.Combine(pluginDir, "PluginStruct.json"); + private static LoaderInfo? ParseLoaderStructFromJson(string loaderStructJson) + { + try + { + return System.Text.Json.JsonSerializer.Deserialize(loaderStructJson, + new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + } + catch (Exception ex) + { + Log.Warning(ex, "Failed to parse LoaderStruct JSON"); + return null; + } + } - PluginInfo? pluginInfo = null; - LoaderInfo? loaderInfo = null; + private static PluginInfo? ParsePluginStructFromJson(string pluginStructJson) + { + try + { + return System.Text.Json.JsonSerializer.Deserialize(pluginStructJson, + new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true, IncludeFields = true }); + } + catch (Exception ex) + { + Log.Warning(ex, "Failed to parse PluginStruct JSON"); + return null; + } + } - // Parse LoaderStruct.json if exists - if (File.Exists(loaderStructPath)) - { - try - { - var loaderStructJson = await File.ReadAllTextAsync(loaderStructPath); - var loaderStruct = System.Text.Json.JsonSerializer.Deserialize(loaderStructJson); + private async Task FinalizeImport( + string pluginDir, string kxpFilePath, + LoaderInfo? loaderInfo, PluginInfo? pluginInfo) + { + const string location = $"{nameof(PluginsManager)}.{nameof(FinalizeImport)}"; - loaderInfo = new LoaderInfo - { - LoaderName = loaderStruct.TryGetProperty("LoaderName", out var name) ? name.GetString() ?? "Unknown" : "Unknown", - LoaderVersion = loaderStruct.TryGetProperty("LoaderVersion", out var version) ? version.GetString() ?? "1.0.0" : "1.0.0", - LoaderLanguage = loaderStruct.TryGetProperty("LoaderLanguage", out var lang) ? lang.GetString() ?? "Unknown" : "Unknown", - LoaderFramework = loaderStruct.TryGetProperty("LoaderFramework", out var fw) ? fw.GetString() ?? "Unknown" : "Unknown", - SelfLoad = loaderStruct.TryGetProperty("SelfLoad", out var selfLoad) && selfLoad.GetBoolean(), - Tags = new Dictionary() - }; + try + { + var pluginFileName = Path.GetFileNameWithoutExtension(kxpFilePath); - // Copy loader struct to LoaderInfo.json (different format) - var loaderInfoJson = System.Text.Json.JsonSerializer.Serialize(loaderInfo, new JsonSerializerOptions { WriteIndented = true }); - await File.WriteAllTextAsync(Path.Combine(pluginDir, "LoaderInfo.json"), loaderInfoJson); + if (pluginInfo == null) + { + // If no plugin info from KXP, look for LoaderStruct/PluginStruct files + var loaderStructPath = Path.Combine(pluginDir, "LoaderStruct.json"); + var pluginStructPath = Path.Combine(pluginDir, "PluginStruct.json"); - Log.Information($"Parsed LoaderStruct: {loaderInfo.LoaderName} v{loaderInfo.LoaderVersion}"); - } - catch (Exception ex) + if (File.Exists(loaderStructPath)) { - Log.Warning(ex, "Failed to parse LoaderStruct.json"); + var json = await File.ReadAllTextAsync(loaderStructPath); + loaderInfo ??= ParseLoaderStructFromJson(json); } - } - // Parse PluginStruct.json if exists (or look for embedded plugin info) - if (File.Exists(pluginStructPath)) - { - try + if (File.Exists(pluginStructPath)) { - var pluginStructJson = await File.ReadAllTextAsync(pluginStructPath); - var pluginStruct = System.Text.Json.JsonSerializer.Deserialize(pluginStructJson); + var json = await File.ReadAllTextAsync(pluginStructPath); + pluginInfo = ParsePluginStructFromJson(json); + } + if (pluginInfo == null) + { pluginInfo = new PluginInfo { - Name = pluginStruct.TryGetProperty("Name", out var name) ? name.GetString() ?? "Unknown" : "Unknown", - Version = pluginStruct.TryGetProperty("Version", out var ver) ? ver.GetString() ?? "1.0.0" : "1.0.0", - DisplayName = new Dictionary(), - SimpleDescription = new Dictionary(), - ComplexDescription = new Dictionary(), + Name = pluginFileName, + Version = "1.0.0", + PublisherName = "Unknown", + AuthorName = "Unknown", + DisplayName = new Dictionary { { "en-us", pluginFileName } }, + SimpleDescription = new Dictionary { { "en-us", "Imported plugin" } }, + ComplexDescription = new Dictionary { { "en-us", "Imported plugin" } }, TotalDescriptionInMarkdown = new Dictionary(), Tags = new Dictionary(), Functions = new List() }; - - // Parse DisplayName - if (pluginStruct.TryGetProperty("DisplayName", out var displayName)) - { - foreach (var prop in displayName.EnumerateObject()) - { - pluginInfo.DisplayName[prop.Name] = prop.Value.GetString() ?? ""; - } - } - - // Parse SimpleDescription - if (pluginStruct.TryGetProperty("SimpleDescription", out var simpleDesc)) - { - foreach (var prop in simpleDesc.EnumerateObject()) - { - pluginInfo.SimpleDescription[prop.Name] = prop.Value.GetString() ?? ""; - } - } - - // Parse ComplexDescription - if (pluginStruct.TryGetProperty("ComplexDescription", out var complexDesc)) - { - foreach (var prop in complexDesc.EnumerateObject()) - { - pluginInfo.ComplexDescription[prop.Name] = prop.Value.GetString() ?? ""; - } - } - - // Parse other fields - pluginInfo.AuthorName = pluginStruct.TryGetProperty("AuthorName", out var author) ? author.GetString() ?? "Unknown" : "Unknown"; - pluginInfo.AuthorLink = pluginStruct.TryGetProperty("AuthorLink", out var authorLink) ? authorLink.GetString() ?? "" : ""; - pluginInfo.PublisherName = pluginStruct.TryGetProperty("PublisherName", out var publisher) ? publisher.GetString() ?? "Unknown" : "Unknown"; - pluginInfo.PublisherLink = pluginStruct.TryGetProperty("PublisherLink", out var publisherLink) ? publisherLink.GetString() ?? "" : ""; - pluginInfo.IconInBase64 = pluginStruct.TryGetProperty("IconInBase64", out var icon) ? icon.GetString() ?? "" : ""; - pluginInfo.IsMarketVersion = pluginStruct.TryGetProperty("IsMarketVersion", out var marketVer) && marketVer.GetBoolean(); - pluginInfo.RootStartupFileName = pluginStruct.TryGetProperty("RootStartupFileName", out var rootFile) ? rootFile.GetString() ?? "" : ""; - - if (pluginStruct.TryGetProperty("PublishDate", out var publishDate)) - { - if (DateTime.TryParse(publishDate.GetString(), out var pd)) - pluginInfo.PublishDate = pd; - } - - if (pluginStruct.TryGetProperty("LastUpdateDate", out var lastUpdate)) - { - if (DateTime.TryParse(lastUpdate.GetString(), out var lud)) - pluginInfo.LastUpdateDate = lud; - } - - Log.Information($"Parsed PluginStruct: {pluginInfo.Name} v{pluginInfo.Version}"); - } - catch (Exception ex) - { - Log.Warning(ex, "Failed to parse PluginStruct.json"); } } - // If no plugin info from KXP, create basic info from filename - if (pluginInfo == null) + // Write LoaderInfo.json + if (loaderInfo != null) { - pluginInfo = new PluginInfo - { - Name = pluginFileName, - Version = "1.0.0", - PublisherName = "Unknown", - AuthorName = "Unknown", - DisplayName = new Dictionary { { "en-us", pluginFileName } }, - SimpleDescription = new Dictionary { { "en-us", "Imported plugin" } }, - ComplexDescription = new Dictionary { { "en-us", "Imported plugin" } }, - TotalDescriptionInMarkdown = new Dictionary(), - Tags = new Dictionary(), - Functions = new List() - }; + var loaderInfoJson = System.Text.Json.JsonSerializer.Serialize(loaderInfo, + new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); + await File.WriteAllTextAsync(Path.Combine(pluginDir, "LoaderInfo.json"), loaderInfoJson); } - // Validate RootStartupFileName is specified and exists + // Validate RootStartupFileName if (string.IsNullOrEmpty(pluginInfo.RootStartupFileName)) { Log.Error($"Plugin import failed: RootStartupFileName is not specified in plugin {pluginInfo.Name}. Please ensure the plugin package includes this field."); @@ -422,13 +411,13 @@ public async Task ImportPluginAsync(string kxpFilePath) if (!File.Exists(pluginFilePath)) { Log.Error($"Plugin import failed: RootStartupFileName '{pluginInfo.RootStartupFileName}' points to a non-existent file in plugin {pluginInfo.Name}. File not found at: {pluginFilePath}"); - // Clean up the created directory try { Directory.Delete(pluginDir, true); } catch { } return false; } - // Create PluginInfo.json - var pluginInfoJson = System.Text.Json.JsonSerializer.Serialize(pluginInfo, new JsonSerializerOptions { WriteIndented = true }); + // Write PluginInfo.json + var pluginInfoJson = System.Text.Json.JsonSerializer.Serialize(pluginInfo, + new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); await File.WriteAllTextAsync(Path.Combine(pluginDir, "PluginInfo.json"), pluginInfoJson); // Create installation record @@ -445,7 +434,6 @@ public async Task ImportPluginAsync(string kxpFilePath) Log.Information($"Imported plugin: {pluginInfo.Name} (v{pluginInfo.Version}) to {pluginDir}"); - // Raise plugin status changed event PluginStatusChanged?.Invoke(this, new PluginStatusChangedEventArgs { PluginId = installation.Id, @@ -458,7 +446,7 @@ public async Task ImportPluginAsync(string kxpFilePath) } catch (Exception ex) { - Log.Error(ex, $"In {location}: Error importing plugin {kxpFilePath}: {ex.Message}"); + Log.Error(ex, $"In {location}: {ex.Message}"); return false; } } From 5c29ed374783be94641f74da6ddfc989cef0e2ae Mon Sep 17 00:00:00 2001 From: StarInk Date: Sun, 3 May 2026 01:14:56 +0200 Subject: [PATCH 064/110] =?UTF-8?q?=F0=9F=93=9D=20Chore(Submodules):=20Syn?= =?UTF-8?q?c.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- KitX SDK | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/KitX SDK b/KitX SDK index 73bfe588..1e5c4c92 160000 --- a/KitX SDK +++ b/KitX SDK @@ -1 +1 @@ -Subproject commit 73bfe588f821e76984059cd3d4d5df95ec0a6ef0 +Subproject commit 1e5c4c92eef0ab40c68e58b2ea6ab4d359ecfee8 From 6b74791e09d56f95083da9ec9d7091ca59d65ddd Mon Sep 17 00:00:00 2001 From: StarInk Date: Tue, 5 May 2026 17:31:55 +0200 Subject: [PATCH 065/110] =?UTF-8?q?=F0=9F=92=BE=20Feat(Core):=20Add=20buil?= =?UTF-8?q?t-in=20functions=20for=20workflow=20and=20plugin=20management?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Config/AppConfig.json | 6 +- Config/PluginsConfig.json | 6 +- Config/SecurityConfig.json | 6 +- .../KitX.Core.BluePrint.Test/Program.cs | 454 ++++++++++++++++++ .../CreateWorkflowFunction.cs | 112 +++++ .../GetPluginInfoByNameFunction.cs | 121 +++++ .../BuiltinFunctions/InstallPluginFunction.cs | 90 ++++ .../BuiltinFunctions/JsonGetFieldFunction.cs | 153 ++++++ .../ListPluginNamesFunction.cs | 96 ++++ .../BuiltinFunctions/ListWorkflowsFunction.cs | 98 ++++ .../BuiltinFunctions/ReadTextFileFunction.cs | 87 ++++ .../BuiltinFunctions/RunWorkflowFunction.cs | 89 ++++ .../BuiltinFunctions/StartPluginFunction.cs | 93 ++++ .../BuiltinFunctions/StopPluginFunction.cs | 93 ++++ .../BuiltinFunctions/StopWorkflowFunction.cs | 89 ++++ .../BuiltinFunctions/WriteTextFileFunction.cs | 89 ++++ .../BlockScripting/ScriptCodeGenerator.cs | 8 + 17 files changed, 1675 insertions(+), 15 deletions(-) create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/CreateWorkflowFunction.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/GetPluginInfoByNameFunction.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/InstallPluginFunction.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/JsonGetFieldFunction.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/ListPluginNamesFunction.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/ListWorkflowsFunction.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/ReadTextFileFunction.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/RunWorkflowFunction.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/StartPluginFunction.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/StopPluginFunction.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/StopWorkflowFunction.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/WriteTextFileFunction.cs diff --git a/Config/AppConfig.json b/Config/AppConfig.json index 87b0dc6c..056cf7bb 100644 --- a/Config/AppConfig.json +++ b/Config/AppConfig.json @@ -1,5 +1,4 @@ { - "$type": "AppConfig", "App": { "IconFileName": "KitX-Icon-1920x-margin-2x.png", "CoverIconFileName": "KitX-Icon-Background.png", @@ -136,8 +135,5 @@ }, "Loaders": { "InstallPath": "./Loaders/" - }, - "ConfigFileLocation": "G:\\Dev\\KitX\\Config\\AppConfig.json", - "ConfigFileWatcherName": null, - "ConfigGeneratedTime": "2024-09-15T15:03:50.4380847+03:00" + } } \ No newline at end of file diff --git a/Config/PluginsConfig.json b/Config/PluginsConfig.json index 3ae98ef1..dcffe4d4 100644 --- a/Config/PluginsConfig.json +++ b/Config/PluginsConfig.json @@ -1,7 +1,3 @@ { - "$type": "PluginsConfig", - "Plugins": [], - "ConfigFileLocation": "G:\\Dev\\KitX\\Config\\PluginsConfig.json", - "ConfigFileWatcherName": null, - "ConfigGeneratedTime": "2024-09-15T15:03:50.5186505+03:00" + "Plugins": [] } \ No newline at end of file diff --git a/Config/SecurityConfig.json b/Config/SecurityConfig.json index 23d36988..6fd074a6 100644 --- a/Config/SecurityConfig.json +++ b/Config/SecurityConfig.json @@ -1,7 +1,3 @@ { - "$type": "SecurityConfig", - "DeviceKeys": [], - "ConfigFileLocation": "G:\\Dev\\KitX\\Config\\SecurityConfig.json", - "ConfigFileWatcherName": null, - "ConfigGeneratedTime": "2024-09-15T15:03:50.5276407+03:00" + "DeviceKeys": [] } \ No newline at end of file diff --git a/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs b/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs index ee9addf2..ac669013 100644 --- a/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs +++ b/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs @@ -172,6 +172,30 @@ public static void Main(string[] args) Console.WriteLine("└──────────────────────────────────────────┘\n"); RunCrossDevicePluginCallTest(converter, reverseConverter, parser, sp); } + + if (ShouldRunTest("N")) + { + Console.WriteLine("\n┌──────────────────────────────────────────┐"); + Console.WriteLine("│ Test N: JsonGetField Pipeline │"); + Console.WriteLine("└──────────────────────────────────────────┘\n"); + RunJsonGetFieldTest(converter, reverseConverter, parser, sp); + } + + if (ShouldRunTest("O")) + { + Console.WriteLine("\n┌──────────────────────────────────────────┐"); + Console.WriteLine("│ Test O: Built-in Func Format/NodeBuild │"); + Console.WriteLine("└──────────────────────────────────────────┘\n"); + RunBuiltinFuncTest(converter, reverseConverter, helpers); + } + + if (ShouldRunTest("P")) + { + Console.WriteLine("\n┌──────────────────────────────────────────┐"); + Console.WriteLine("│ Test P: Built-in Assembly Compilation │"); + Console.WriteLine("└──────────────────────────────────────────┘\n"); + RunBuiltinAssemblyTest(parser, sp); + } } private static void ParseArgs(string[] args) @@ -1106,4 +1130,434 @@ public T Call(PluginCallInfo callInfo) public bool IsPluginExists(string name) => true; public bool IsMethodExists(string plugin, string method) => true; } + + // ────────────────────────────────────────────── + // Test N: JsonGetField Pipeline + // ────────────────────────────────────────────── + private static void RunJsonGetFieldTest( + BlockScriptToBlueprintConverter forwardConverter, + IBlueprintToBlockScriptConverter reverseConverter, + IBlockScriptParser parser, + System.IServiceProvider sp) + { + bool allPassed = true; + + // ── Phase 1: Forward Conversion ── + Console.WriteLine("[Test N] Phase 1: Forward (Script → Blueprint)"); + try + { + var sourceCode = GetJsonGetFieldScript(); + var blueprint = forwardConverter.Convert(sourceCode, new List()); + + Console.WriteLine($" Nodes: {blueprint.Nodes.Count}, Connections: {blueprint.Connections.Count}"); + Console.WriteLine($" Node types: {string.Join(", ", blueprint.Nodes.Select(n => n.NodeType).Distinct())}"); + + // Find BuiltinFunctionNodes for JsonGetField + var bfnNodes = blueprint.Nodes.OfType() + .Where(n => n.FunctionName == "JsonGetField").ToList(); + Console.WriteLine($" JsonGetField nodes: {bfnNodes.Count}"); + foreach (var bfn in bfnNodes) + { + Console.WriteLine($" {bfn.Name}: FieldPath={bfn.Properties.GetValueOrDefault("FieldPath", "?")}"); + } + + bool phase1pass = bfnNodes.Count >= 2; + Console.WriteLine($"[Test N] Phase 1: {(phase1pass ? "PASS" : "FAIL")}"); + if (!phase1pass) allPassed = false; + } + catch (Exception ex) + { + Console.WriteLine($"[Test N] Phase 1 FAILED: {ex.Message}"); + allPassed = false; + } + + // ── Phase 2: Reverse Conversion ── + Console.WriteLine("\n[Test N] Phase 2: Reverse (Blueprint → Script)"); + try + { + var sourceCode = GetJsonGetFieldScript(); + var blueprint = forwardConverter.Convert(sourceCode, new List()); + var expandedScript = reverseConverter.Convert(blueprint); + + Console.WriteLine($" Expanded script ({expandedScript.Length} chars):"); + Console.WriteLine(expandedScript); + + bool containsJsonGetField = expandedScript.Contains("JsonGetField"); + Console.WriteLine($" Contains JsonGetField: {containsJsonGetField}"); + + bool phase2pass = containsJsonGetField; + Console.WriteLine($"[Test N] Phase 2: {(phase2pass ? "PASS" : "FAIL")}"); + if (!phase2pass) allPassed = false; + } + catch (Exception ex) + { + Console.WriteLine($"[Test N] Phase 2 FAILED: {ex.Message}"); + allPassed = false; + } + + // ── Phase 3: Assembly Compilation ── + Console.WriteLine("\n[Test N] Phase 3: Assembly Compilation"); + try + { + var sourceCode = GetJsonGetFieldScript(); + var parseResult = parser.Parse(sourceCode); + if (!parseResult.IsSuccess || parseResult.Script == null) + { + Console.WriteLine($"[Test N] Phase 3 FAILED: Parse error: {parseResult.ErrorMessage}"); + allPassed = false; + } + else + { + var compiler = new ScriptAssemblyCompiler(); + var compiled = compiler.CompileScript(parseResult.Script); + Console.WriteLine($" Assembly compilation: {(compiled != null ? "SUCCESS" : "FAILED (null)")}"); + + if (compiled != null) + { + var output = new List(); + var globals = new BlockScriptExecutionGlobals( + new BlockScopeManager(), output); + globals.ResetRunState(); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + try + { + compiled.Run(globals, cts.Token); + Console.WriteLine($" Execution: SUCCESS"); + Console.WriteLine($" Output ({output.Count} lines): [{string.Join(", ", output)}]"); + + // Expected: "url_value", "name_value", "top_level", "nested_value" + bool outputOk = output.Count >= 3; + Console.WriteLine($"[Test N] Phase 3: {(outputOk ? "PASS" : "FAIL - wrong output count")}"); + if (!outputOk) allPassed = false; + } + catch (Exception ex) + { + Console.WriteLine($"[Test N] Phase 3 FAILED (run): {ex.GetType().Name}: {ex.Message}"); + if (ex.InnerException != null) + Console.WriteLine($" Inner: {ex.InnerException.GetType().Name}: {ex.InnerException.Message}"); + allPassed = false; + } + } + else + { + allPassed = false; + } + } + } + catch (Exception ex) + { + Console.WriteLine($"[Test N] Phase 3 FAILED: {ex.Message}"); + allPassed = false; + } + + Console.WriteLine($"\n[Test N] {(allPassed ? "PASS - All phases passed!" : "FAIL - See above for details")}"); + } + + // ────────────────────────────────────────────── + // Test N source: JsonGetField usage + // ────────────────────────────────────────────── + private static string GetJsonGetFieldScript() => @"#ConstBlock +string jsonData = ""{\""url\"":\""https://example.com/plugin.kxp\"",\""name\"":\""WeatherPlugin\"",\""nested\"":{\""inner\"":\""value\"",\""items\"":[0,1,2]}}""; +string fieldUrl = ""url""; +string fieldName = ""name""; +string fieldNested = ""nested.inner""; + +#PubVarBlock +string urlValue; +string nameValue; +string topLevel; +string nestedValue; + +#MainBlock +Print(""Testing JsonGetField""); +urlValue = JsonGetField(jsonData, fieldUrl); +nameValue = JsonGetField(jsonData, fieldName); +topLevel = JsonGetField(jsonData, ""url""); +nestedValue = JsonGetField(jsonData, fieldNested); +Print(urlValue); +Print(nameValue); +Print(topLevel); +Print(nestedValue); +Print(""JsonGetField test done"");"; + + // ────────────────────────────────────────────── + // Test O: Built-in Function Format/NodeBuild + // ────────────────────────────────────────────── + private static void RunBuiltinFuncTest( + BlockScriptToBlueprintConverter forwardConverter, + IBlueprintToBlockScriptConverter reverseConverter, + List helpers) + { + bool allPassed = true; + + // ── Sub-test O1: ListPluginNames/ListWorkflows (zero-arg functions) ── + Console.WriteLine("[Test O1] Zero-arg built-in functions (ListPluginNames, ListWorkflows)"); + try + { + var sourceCode = GetZeroArgBuiltinScript(); + var blueprint = forwardConverter.Convert(sourceCode, helpers); + + Console.WriteLine($" Nodes: {blueprint.Nodes.Count}"); + var bfnNodes = blueprint.Nodes.OfType().ToList(); + Console.WriteLine($" BuiltinFunctionNodes: {bfnNodes.Count}"); + foreach (var bfn in bfnNodes) + Console.WriteLine($" {bfn.FunctionName} ({bfn.Name})"); + + bool o1pass = bfnNodes.Any(n => n.FunctionName == "ListPluginNames") && + bfnNodes.Any(n => n.FunctionName == "ListWorkflows"); + Console.WriteLine($"[Test O1] {(o1pass ? "PASS" : "FAIL")}"); + if (!o1pass) allPassed = false; + } + catch (Exception ex) + { + Console.WriteLine($"[Test O1] FAILED: {ex.Message}"); + allPassed = false; + } + + // ── Sub-test O2: InstallPlugin/StartPlugin (single-arg true) ── + Console.WriteLine("\n[Test O2] Single-arg built-in functions (InstallPlugin, StartPlugin, StopPlugin)"); + try + { + var sourceCode = GetSingleArgBuiltinScript(); + var blueprint = forwardConverter.Convert(sourceCode, helpers); + + Console.WriteLine($" Nodes: {blueprint.Nodes.Count}"); + var bfnNodes = blueprint.Nodes.OfType().ToList(); + Console.WriteLine($" BuiltinFunctionNodes: {bfnNodes.Count}"); + + bool o2pass = bfnNodes.Any(n => n.FunctionName == "InstallPlugin") && + bfnNodes.Any(n => n.FunctionName == "StartPlugin") && + bfnNodes.Any(n => n.FunctionName == "StopPlugin"); + Console.WriteLine($"[Test O2] {(o2pass ? "PASS" : "FAIL")}"); + if (!o2pass) allPassed = false; + } + catch (Exception ex) + { + Console.WriteLine($"[Test O2] FAILED: {ex.Message}"); + allPassed = false; + } + + // ── Sub-test O3: Read/Write file + CreateWorkflow (multi-arg) ── + Console.WriteLine("\n[Test O3] Multi-arg functions (ReadTextFile, WriteTextFile, CreateWorkflow)"); + try + { + var sourceCode = GetMultiArgBuiltinScript(); + var blueprint = forwardConverter.Convert(sourceCode, helpers); + + Console.WriteLine($" Nodes: {blueprint.Nodes.Count}"); + var bfnNodes = blueprint.Nodes.OfType().ToList(); + Console.WriteLine($" BuiltinFunctionNodes: {bfnNodes.Count}"); + + bool o3pass = bfnNodes.Any(n => n.FunctionName == "ReadTextFile") && + bfnNodes.Any(n => n.FunctionName == "WriteTextFile") && + bfnNodes.Any(n => n.FunctionName == "CreateWorkflow"); + Console.WriteLine($"[Test O3] {(o3pass ? "PASS" : "FAIL")}"); + if (!o3pass) allPassed = false; + } + catch (Exception ex) + { + Console.WriteLine($"[Test O3] FAILED: {ex.Message}"); + allPassed = false; + } + + // ── Sub-test O4: Round-trip for multi-arg script ── + Console.WriteLine("\n[Test O4] Round-trip (multi-arg)"); + try + { + var sourceCode = GetMultiArgBuiltinScript(); + var bp1 = forwardConverter.Convert(sourceCode, helpers); + var script1 = reverseConverter.Convert(bp1); + + Console.WriteLine($" Round 1 expanded script ({script1.Length} chars)"); + + // Check that function names survive the round-trip + bool containsRead = script1.Contains("ReadTextFile"); + bool containsWrite = script1.Contains("WriteTextFile"); + bool containsCreate = script1.Contains("CreateWorkflow"); + Console.WriteLine($" Contains ReadTextFile: {containsRead}, WriteTextFile: {containsWrite}, CreateWorkflow: {containsCreate}"); + + bool o4pass = containsRead && containsWrite && containsCreate; + Console.WriteLine($"[Test O4] {(o4pass ? "PASS" : "FAIL")}"); + if (!o4pass) allPassed = false; + } + catch (Exception ex) + { + Console.WriteLine($"[Test O4] FAILED: {ex.Message}"); + allPassed = false; + } + + Console.WriteLine($"\n[Test O] {(allPassed ? "PASS - All sub-tests passed!" : "FAIL - See above for details")}"); + } + + // ────────────────────────────────────────────── + // Test O source scripts + // ────────────────────────────────────────────── + private static string GetZeroArgBuiltinScript() => @"#ConstBlock +string result = """"; + +#MainBlock +Print(""Testing zero-arg builtins""); +result = ListPluginNames(); +Print(result); +result = ListWorkflows(); +Print(result); +Print(""Zero-arg test done"");"; + + private static string GetSingleArgBuiltinScript() => @"#ConstBlock +string pluginPath = ""/path/to/plugin.kxp""; +string pluginName = ""TestPlugin""; +bool installOk; +bool startOk; +bool stopOk; + +#MainBlock +Print(""Testing single-arg builtins""); +installOk = InstallPlugin(pluginPath); +Print(installOk); +startOk = StartPlugin(pluginName); +Print(startOk); +stopOk = StopPlugin(pluginName); +Print(stopOk); +Print(""Single-arg test done"");"; + + private static string GetMultiArgBuiltinScript() => @"#ConstBlock +string fileName = ""/tmp/test.txt""; +string fileContent = ""Hello from AI assistant!""; +string wfName = ""AI_Test""; +string wfSource = ""#MainBlock\nPrint(\""test\"");""; + +#PubVarBlock +string content; +string workflowId; + +#MainBlock +Print(""Testing multi-arg builtins""); +content = ReadTextFile(fileName); +Print(content); +WriteTextFile(fileName, fileContent); +workflowId = CreateWorkflow(wfName, wfSource); +Print(workflowId); +Print(""Multi-arg test done"");"; + + // ────────────────────────────────────────────── + // Test P: Built-in Assembly Compilation + // ────────────────────────────────────────────── + private static void RunBuiltinAssemblyTest( + IBlockScriptParser parser, + System.IServiceProvider sp) + { + bool allPassed = true; + + Console.WriteLine("[Test P1] Simple built-in assembly compilation + execution"); + try + { + var sourceCode = @"#ConstBlock +int valueA = 10; +int valueB = 20; +string filePath = ""/tmp/test_output.txt""; + +#PubVarBlock +string output; + +#MainBlock +Print(""Testing builtins via assembly""); +WriteTextFile(filePath, ""Hello from builtin test""); +Print(""File write attempted""); +output = ReadTextFile(filePath); +Print(output); +Print(""Builtin assembly test done"");"; + + var parseResult = parser.Parse(sourceCode); + if (!parseResult.IsSuccess || parseResult.Script == null) + { + Console.WriteLine($" Parse FAILED: {parseResult.ErrorMessage}"); + allPassed = false; + } + else + { + var compiler = new ScriptAssemblyCompiler(); + ICompiledBlockScript? compiled = null; + try + { + compiled = compiler.CompileScript(parseResult.Script); + } + catch (Exception cex) + { + Console.WriteLine($" Compilation EXCEPTION: {cex.GetType().Name}: {cex.Message}"); + if (cex.InnerException != null) + Console.WriteLine($" Inner: {cex.InnerException.GetType().Name}: {cex.InnerException.Message}"); + } + Console.WriteLine($" Compilation: {(compiled != null ? "SUCCESS" : "FAILED")}"); + + if (compiled != null) + { + var output = new List(); + var scopeManager = new BlockScopeManager(); + var globals = new BlockScriptExecutionGlobals(scopeManager, output); + globals.ResetRunState(); + scopeManager.InitializeGlobalScope(parseResult.Script); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + try + { + compiled.Run(globals, cts.Token); + Console.WriteLine($" Execution: OK, output ({output.Count}): [{string.Join(", ", output)}]"); + + bool outputOk = output.Any(o => o.Contains("Hello from builtin test")); + Console.WriteLine($"[Test P1] {(outputOk ? "PASS" : "FAIL - wrong output")}"); + if (!outputOk) allPassed = false; + } + catch (Exception ex) + { + Console.WriteLine($" Execution FAILED: {ex.GetType().Name}: {ex.Message}"); + if (ex.InnerException != null) + Console.WriteLine($" Inner: {ex.InnerException.GetType().Name}: {ex.InnerException.Message}"); + allPassed = false; + } + } + else + { + allPassed = false; + } + } + } + catch (Exception ex) + { + Console.WriteLine($"[Test P1] FAILED: {ex.Message}"); + allPassed = false; + } + + Console.WriteLine("\n[Test P2] All built-ins present in registry"); + try + { + var funcRegistry = sp.GetRequiredService(); + var expected = new[] { + "ListPluginNames", "GetPluginInfoByName", "InstallPlugin", + "StartPlugin", "StopPlugin", "ListWorkflows", "RunWorkflow", + "StopWorkflow", "CreateWorkflow", "JsonGetField", + "ReadTextFile", "WriteTextFile" + }; + + bool allFound = true; + Console.WriteLine($" Registered functions: {funcRegistry.AllFunctionNames.Count}"); + foreach (var name in expected) + { + var def = funcRegistry.Get(name); + bool found = def != null; + Console.WriteLine($" {name}: {(found ? "FOUND" : "MISSING")}"); + if (!found) allFound = false; + } + + Console.WriteLine($"[Test P2] {(allFound ? "PASS" : "FAIL")}"); + if (!allFound) allPassed = false; + } + catch (Exception ex) + { + Console.WriteLine($"[Test P2] FAILED: {ex.Message}"); + allPassed = false; + } + + Console.WriteLine($"\n[Test P] {(allPassed ? "PASS - All phases passed!" : "FAIL - See above for details")}"); + } } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/CreateWorkflowFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/CreateWorkflowFunction.cs new file mode 100644 index 00000000..055ba455 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/CreateWorkflowFunction.cs @@ -0,0 +1,112 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using KitX.Core.Contract.Workflow; +using KitX.Core.DI; +using KitX.Core.Workflow.Blueprint; +using KitX.Core.Workflow.Blueprint.CFG; +using KitX.Core.Workflow.Blueprint.Pipeline; +using Serilog; + +namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions +{ + public class CreateWorkflowFunction : IBuiltinFunctionDefinition + { + public string FunctionName => "CreateWorkflow"; + public string DisplayName => "Create Workflow"; + public bool IsFlowControl => false; + public bool IsNonExtractable => true; + public BlueprintNodeType? LegacyNodeType => null; + public CFGStatementKind StatementKind => CFGStatementKind.Expression; + public double NodeWidth => 180; + public double NodeHeight => 80; + + public IReadOnlyList InputPins => [ + new("Exec", PinType.Execution, 20), + new("Name", PinType.String, 35), + new("Source", PinType.String, 35) + ]; + + public IReadOnlyList OutputPins => [ + new("Exec", PinType.Execution, 20), + new("Return", PinType.String, 40) + ]; + + public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; + + public List FormatInvocation( + InvocationExpressionSyntax invoke, string blockName, + PipelineContext context, string? assignedVar) + { + var args = invoke.ArgumentList.Arguments.Select(a => a.Expression.ToString()).ToList(); + return [new FormattedStatement + { + BlockName = blockName, + Kind = CFGStatementKind.Expression, + FunctionName = FunctionName, + Arguments = args, + OriginalExpression = invoke.ToString(), + SourceLine = 0, + }]; + } + + public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) + { + if (node is BuiltinFunctionNode bfn && stmt.Arguments?.Count > 0) + bfn.Properties["Name"] = stmt.Arguments[0]; + return node; + } + + public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) + { + var name = helper.GetInputValue(node, "Name"); + var source = helper.GetInputValue(node, "Source"); + return new ExpressionStatement + { + Expression = $"{FunctionName}({name}, {source})", + SourceCode = $"{FunctionName}({name}, {source});", + LineNumber = 1 + }; + } + + public IEnumerable GetOutputArms() => []; + } +} + +namespace KitX.Core.Workflow.BlockScripting +{ + public partial class BlockScriptExecutionGlobals + { + public string CreateWorkflow(string name, string blockScriptSource) + { + if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(blockScriptSource)) return ""; + if (!DI.ServiceHost.IsInitialized) return ""; + try + { + var storage = DI.ServiceHost.GetRequiredService(); + var workflowId = Guid.NewGuid().ToString(); + var kcsData = new KcsFileFormat + { + Id = workflowId, + Name = name, + CreatedTime = DateTime.UtcNow, + LastModifiedTime = DateTime.UtcNow, + UseBlockMode = true, + BlockScriptSource = blockScriptSource, + TriggerType = "Manual", + Author = "AI Assistant", + Description = "Workflow generated by KitX AI Assistant" + }; + + storage.SaveWorkflowDataAsync(workflowId, kcsData).GetAwaiter().GetResult(); + Log.Information("[BlockScriptGlobals] CreateWorkflow: {Id} '{Name}'", workflowId, name); + return workflowId; + } + catch (Exception ex) + { + Log.Error(ex, "[BlockScriptGlobals] CreateWorkflow failed for '{Name}'", name); + return ""; + } + } + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/GetPluginInfoByNameFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/GetPluginInfoByNameFunction.cs new file mode 100644 index 00000000..01cd91e8 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/GetPluginInfoByNameFunction.cs @@ -0,0 +1,121 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using KitX.Core.Contract.Plugin; +using KitX.Core.Contract.Workflow; +using KitX.Core.DI; +using KitX.Core.Workflow.Blueprint; +using KitX.Core.Workflow.Blueprint.CFG; +using KitX.Core.Workflow.Blueprint.Pipeline; +using Serilog; + +namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions +{ + public class GetPluginInfoByNameFunction : IBuiltinFunctionDefinition + { + public string FunctionName => "GetPluginInfoByName"; + public string DisplayName => "Get Plugin Info"; + public bool IsFlowControl => false; + public bool IsNonExtractable => true; + public BlueprintNodeType? LegacyNodeType => null; + public CFGStatementKind StatementKind => CFGStatementKind.Expression; + public double NodeWidth => 160; + public double NodeHeight => 60; + + public IReadOnlyList InputPins => [ + new("Exec", PinType.Execution, 20), + new("PluginName", PinType.String, 35) + ]; + + public IReadOnlyList OutputPins => [ + new("Exec", PinType.Execution, 20), + new("Return", PinType.String, 40) + ]; + + public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; + + public List FormatInvocation( + InvocationExpressionSyntax invoke, string blockName, + PipelineContext context, string? assignedVar) + { + var args = invoke.ArgumentList.Arguments.Select(a => a.Expression.ToString()).ToList(); + return [new FormattedStatement + { + BlockName = blockName, + Kind = CFGStatementKind.Expression, + FunctionName = FunctionName, + Arguments = args, + OriginalExpression = invoke.ToString(), + SourceLine = 0, + }]; + } + + public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) + { + if (node is BuiltinFunctionNode bfn && stmt.Arguments?.Count > 0) + bfn.Properties["PluginName"] = StripQuotes(stmt.Arguments[0]); + return node; + } + + public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) + { + var pluginName = node is BuiltinFunctionNode bfn + ? bfn.Properties.GetValueOrDefault("PluginName", "") ?? "" + : ""; + return new ExpressionStatement + { + Expression = $"{FunctionName}(\"{pluginName}\")", + SourceCode = $"{FunctionName}(\"{pluginName}\");", + LineNumber = 1 + }; + } + + public IEnumerable GetOutputArms() => []; + + private static string StripQuotes(string s) + { + s = s?.Trim() ?? ""; + if (s.Length >= 2 && s[0] == '"' && s[^1] == '"') + return s[1..^1]; + return s; + } + } +} + +namespace KitX.Core.Workflow.BlockScripting +{ + public partial class BlockScriptExecutionGlobals + { + public string GetPluginInfoByName(string pluginName) + { + if (string.IsNullOrEmpty(pluginName)) return "{}"; + if (!DI.ServiceHost.IsInitialized) return "{}"; + try + { + var pluginService = DI.ServiceHost.GetRequiredService(); + var plugin = pluginService.GetInstalledPlugins() + .FirstOrDefault(p => p.PluginInfo?.Name == pluginName); + if (plugin?.PluginInfo == null) return "{}"; + + return JsonSerializer.Serialize(new + { + plugin.PluginInfo.Name, + plugin.PluginInfo.Version, + IsRunning = true, + Functions = plugin.PluginInfo.Functions?.Select(f => new + { + f.Name, + f.ReturnValueType, + Parameters = f.Parameters?.Select(p => new { p.Name, p.Type }) + }) + }); + } + catch (Exception ex) + { + Log.Error(ex, "[BlockScriptGlobals] GetPluginInfoByName failed for {Name}", pluginName); + return "{}"; + } + } + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/InstallPluginFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/InstallPluginFunction.cs new file mode 100644 index 00000000..7581c930 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/InstallPluginFunction.cs @@ -0,0 +1,90 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using KitX.Core.Contract.Plugin; +using KitX.Core.Contract.Workflow; +using KitX.Core.DI; +using KitX.Core.Workflow.Blueprint; +using KitX.Core.Workflow.Blueprint.CFG; +using KitX.Core.Workflow.Blueprint.Pipeline; +using Serilog; + +namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions +{ + public class InstallPluginFunction : IBuiltinFunctionDefinition + { + public string FunctionName => "InstallPlugin"; + public string DisplayName => "Install Plugin"; + public bool IsFlowControl => false; + public bool IsNonExtractable => true; + public BlueprintNodeType? LegacyNodeType => null; + public CFGStatementKind StatementKind => CFGStatementKind.Expression; + public double NodeWidth => 140; + public double NodeHeight => 60; + + public IReadOnlyList InputPins => [ + new("Exec", PinType.Execution, 20), + new("KxpPath", PinType.String, 35) + ]; + + public IReadOnlyList OutputPins => [ + new("Exec", PinType.Execution, 20), + new("Return", PinType.Boolean, 40) + ]; + + public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; + + public List FormatInvocation( + InvocationExpressionSyntax invoke, string blockName, + PipelineContext context, string? assignedVar) + { + var args = invoke.ArgumentList.Arguments.Select(a => a.Expression.ToString()).ToList(); + return [new FormattedStatement + { + BlockName = blockName, + Kind = CFGStatementKind.Expression, + FunctionName = FunctionName, + Arguments = args, + OriginalExpression = invoke.ToString(), + SourceLine = 0, + }]; + } + + public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) => node; + + public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) + { + var value = helper.GetInputValue(node, "KxpPath"); + return new ExpressionStatement + { + Expression = $"{FunctionName}({value})", + SourceCode = $"{FunctionName}({value});", + LineNumber = 1 + }; + } + + public IEnumerable GetOutputArms() => []; + } +} + +namespace KitX.Core.Workflow.BlockScripting +{ + public partial class BlockScriptExecutionGlobals + { + public bool InstallPlugin(string kxpPath) + { + if (string.IsNullOrEmpty(kxpPath)) return false; + if (!DI.ServiceHost.IsInitialized) return false; + try + { + var pluginService = DI.ServiceHost.GetRequiredService(); + return pluginService.ImportPluginAsync(kxpPath).GetAwaiter().GetResult(); + } + catch (Exception ex) + { + Log.Error(ex, "[BlockScriptGlobals] InstallPlugin failed for {Path}", kxpPath); + return false; + } + } + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/JsonGetFieldFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/JsonGetFieldFunction.cs new file mode 100644 index 00000000..20b7cb9d --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/JsonGetFieldFunction.cs @@ -0,0 +1,153 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using KitX.Core.Contract.Workflow; +using KitX.Core.Workflow.Blueprint; +using KitX.Core.Workflow.Blueprint.CFG; +using KitX.Core.Workflow.Blueprint.Pipeline; +using Serilog; + +namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions +{ + public class JsonGetFieldFunction : IBuiltinFunctionDefinition + { + public string FunctionName => "JsonGetField"; + public string DisplayName => "JSON Get Field"; + public bool IsFlowControl => false; + public bool IsNonExtractable => true; + public BlueprintNodeType? LegacyNodeType => null; + public CFGStatementKind StatementKind => CFGStatementKind.Expression; + public double NodeWidth => 160; + public double NodeHeight => 80; + + public IReadOnlyList InputPins => [ + new("Exec", PinType.Execution, 20), + new("Json", PinType.String, 35), + new("FieldPath", PinType.String, 35) + ]; + + public IReadOnlyList OutputPins => [ + new("Exec", PinType.Execution, 20), + new("Return", PinType.String, 40) + ]; + + public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; + + public List FormatInvocation( + InvocationExpressionSyntax invoke, string blockName, + PipelineContext context, string? assignedVar) + { + var args = invoke.ArgumentList.Arguments.Select(a => a.Expression.ToString()).ToList(); + return [new FormattedStatement + { + BlockName = blockName, + Kind = CFGStatementKind.Expression, + FunctionName = FunctionName, + Arguments = args, + OriginalExpression = invoke.ToString(), + SourceLine = 0, + }]; + } + + public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) + { + if (node is BuiltinFunctionNode bfn && stmt.Arguments?.Count > 1) + bfn.Properties["FieldPath"] = StripQuotes(stmt.Arguments[1]); + return node; + } + + public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) + { + var jsonValue = helper.GetInputValue(node, "Json"); + var fieldPath = node is BuiltinFunctionNode bfn + ? bfn.Properties.GetValueOrDefault("FieldPath", "") ?? "" + : ""; + return new ExpressionStatement + { + Expression = $"{FunctionName}({jsonValue}, \"{fieldPath}\")", + SourceCode = $"{FunctionName}({jsonValue}, \"{fieldPath}\");", + LineNumber = 1 + }; + } + + public IEnumerable GetOutputArms() => []; + + private static string StripQuotes(string s) + { + s = s?.Trim() ?? ""; + if (s.Length >= 2 && s[0] == '"' && s[^1] == '"') + return s[1..^1]; + return s; + } + } +} + +namespace KitX.Core.Workflow.BlockScripting +{ + public partial class BlockScriptExecutionGlobals + { + public string JsonGetField(string json, string fieldPath) + { + if (string.IsNullOrEmpty(json) || string.IsNullOrEmpty(fieldPath)) + return ""; + + try + { + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + var current = root; + + var segments = fieldPath.Split('.'); + foreach (var segment in segments) + { + var bracketIdx = segment.IndexOf('['); + if (bracketIdx > 0) + { + var propName = segment[..bracketIdx]; + var idxStr = segment[(bracketIdx + 1)..^1]; + + if (!string.IsNullOrEmpty(propName)) + { + if (!current.TryGetProperty(propName, out current)) + return ""; + } + + if (int.TryParse(idxStr, out var idx) && + current.ValueKind == JsonValueKind.Array) + { + var arr = current.EnumerateArray().ToList(); + if (idx >= 0 && idx < arr.Count) + current = arr[idx]; + else + return ""; + } + else return ""; + } + else + { + if (!current.TryGetProperty(segment, out current)) + return ""; + } + } + + return current.ValueKind switch + { + JsonValueKind.String => current.GetString() ?? "", + JsonValueKind.Number => current.GetRawText(), + JsonValueKind.True => "true", + JsonValueKind.False => "false", + JsonValueKind.Null => "", + JsonValueKind.Object => current.GetRawText(), + JsonValueKind.Array => current.GetRawText(), + _ => current.GetRawText() + }; + } + catch (Exception ex) + { + Log.Warning(ex, "[BlockScriptGlobals] JsonGetField failed for path {Path}", fieldPath); + return ""; + } + } + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/ListPluginNamesFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/ListPluginNamesFunction.cs new file mode 100644 index 00000000..48cce985 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/ListPluginNamesFunction.cs @@ -0,0 +1,96 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using KitX.Core.Contract.Plugin; +using KitX.Core.Contract.Workflow; +using KitX.Core.DI; +using KitX.Core.Workflow.Blueprint; +using KitX.Core.Workflow.Blueprint.CFG; +using KitX.Core.Workflow.Blueprint.Pipeline; +using Serilog; + +namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions +{ + public class ListPluginNamesFunction : IBuiltinFunctionDefinition + { + public string FunctionName => "ListPluginNames"; + public string DisplayName => "List Plugin Names"; + public bool IsFlowControl => false; + public bool IsNonExtractable => true; + public BlueprintNodeType? LegacyNodeType => null; + public CFGStatementKind StatementKind => CFGStatementKind.Expression; + public double NodeWidth => 140; + public double NodeHeight => 60; + + public IReadOnlyList InputPins => [ + new("Exec", PinType.Execution, 20) + ]; + + public IReadOnlyList OutputPins => [ + new("Exec", PinType.Execution, 20), + new("Return", PinType.String, 40) + ]; + + public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; + + public List FormatInvocation( + InvocationExpressionSyntax invoke, string blockName, + PipelineContext context, string? assignedVar) + { + return [new FormattedStatement + { + BlockName = blockName, + Kind = CFGStatementKind.Expression, + FunctionName = FunctionName, + Arguments = [], + OriginalExpression = invoke.ToString(), + SourceLine = 0, + }]; + } + + public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) => node; + + public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) + { + return new ExpressionStatement + { + Expression = $"{FunctionName}()", + SourceCode = $"{FunctionName}();", + LineNumber = 1 + }; + } + + public IEnumerable GetOutputArms() => []; + } +} + +namespace KitX.Core.Workflow.BlockScripting +{ + public partial class BlockScriptExecutionGlobals + { + public string ListPluginNames() + { + if (!DI.ServiceHost.IsInitialized) + { + Log.Warning("[BlockScriptGlobals] ListPluginNames: ServiceHost not initialized"); + return "[]"; + } + try + { + var pluginService = DI.ServiceHost.GetRequiredService(); + var plugins = pluginService.GetInstalledPlugins(); + var names = plugins + .Where(p => p.PluginInfo != null) + .Select(p => p.PluginInfo!.Name) + .ToList(); + return JsonSerializer.Serialize(names); + } + catch (Exception ex) + { + Log.Error(ex, "[BlockScriptGlobals] ListPluginNames failed"); + return "[]"; + } + } + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/ListWorkflowsFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/ListWorkflowsFunction.cs new file mode 100644 index 00000000..e468fdc6 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/ListWorkflowsFunction.cs @@ -0,0 +1,98 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using KitX.Core.Contract.Workflow; +using KitX.Core.DI; +using KitX.Core.Workflow.Blueprint; +using KitX.Core.Workflow.Blueprint.CFG; +using KitX.Core.Workflow.Blueprint.Pipeline; +using Serilog; + +namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions +{ + public class ListWorkflowsFunction : IBuiltinFunctionDefinition + { + public string FunctionName => "ListWorkflows"; + public string DisplayName => "List Workflows"; + public bool IsFlowControl => false; + public bool IsNonExtractable => true; + public BlueprintNodeType? LegacyNodeType => null; + public CFGStatementKind StatementKind => CFGStatementKind.Expression; + public double NodeWidth => 140; + public double NodeHeight => 60; + + public IReadOnlyList InputPins => [ + new("Exec", PinType.Execution, 20) + ]; + + public IReadOnlyList OutputPins => [ + new("Exec", PinType.Execution, 20), + new("Return", PinType.String, 40) + ]; + + public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; + + public List FormatInvocation( + InvocationExpressionSyntax invoke, string blockName, + PipelineContext context, string? assignedVar) + { + return [new FormattedStatement + { + BlockName = blockName, + Kind = CFGStatementKind.Expression, + FunctionName = FunctionName, + Arguments = [], + OriginalExpression = invoke.ToString(), + SourceLine = 0, + }]; + } + + public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) => node; + + public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) + { + return new ExpressionStatement + { + Expression = $"{FunctionName}()", + SourceCode = $"{FunctionName}();", + LineNumber = 1 + }; + } + + public IEnumerable GetOutputArms() => []; + } +} + +namespace KitX.Core.Workflow.BlockScripting +{ + public partial class BlockScriptExecutionGlobals + { + public string ListWorkflows() + { + if (!DI.ServiceHost.IsInitialized) + { + Log.Warning("[BlockScriptGlobals] ListWorkflows: ServiceHost not initialized"); + return "[]"; + } + try + { + var storage = DI.ServiceHost.GetRequiredService(); + var workflows = storage.DiscoverWorkflowsAsync().GetAwaiter().GetResult(); + var info = workflows.Select(w => new + { + w.Id, + w.Name, + w.Description, + w.TriggerType + }); + return JsonSerializer.Serialize(info); + } + catch (Exception ex) + { + Log.Error(ex, "[BlockScriptGlobals] ListWorkflows failed"); + return "[]"; + } + } + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/ReadTextFileFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/ReadTextFileFunction.cs new file mode 100644 index 00000000..ab66fd2a --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/ReadTextFileFunction.cs @@ -0,0 +1,87 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using KitX.Core.Contract.Workflow; +using KitX.Core.Workflow.Blueprint; +using KitX.Core.Workflow.Blueprint.CFG; +using KitX.Core.Workflow.Blueprint.Pipeline; +using Serilog; + +namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions +{ + public class ReadTextFileFunction : IBuiltinFunctionDefinition + { + public string FunctionName => "ReadTextFile"; + public string DisplayName => "Read Text File"; + public bool IsFlowControl => false; + public bool IsNonExtractable => true; + public BlueprintNodeType? LegacyNodeType => null; + public CFGStatementKind StatementKind => CFGStatementKind.Expression; + public double NodeWidth => 140; + public double NodeHeight => 60; + + public IReadOnlyList InputPins => [ + new("Exec", PinType.Execution, 20), + new("Path", PinType.String, 35) + ]; + + public IReadOnlyList OutputPins => [ + new("Exec", PinType.Execution, 20), + new("Return", PinType.String, 40) + ]; + + public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; + + public List FormatInvocation( + InvocationExpressionSyntax invoke, string blockName, + PipelineContext context, string? assignedVar) + { + var args = invoke.ArgumentList.Arguments.Select(a => a.Expression.ToString()).ToList(); + return [new FormattedStatement + { + BlockName = blockName, + Kind = CFGStatementKind.Expression, + FunctionName = FunctionName, + Arguments = args, + OriginalExpression = invoke.ToString(), + SourceLine = 0, + }]; + } + + public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) => node; + + public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) + { + var value = helper.GetInputValue(node, "Path"); + return new ExpressionStatement + { + Expression = $"{FunctionName}({value})", + SourceCode = $"{FunctionName}({value});", + LineNumber = 1 + }; + } + + public IEnumerable GetOutputArms() => []; + } +} + +namespace KitX.Core.Workflow.BlockScripting +{ + public partial class BlockScriptExecutionGlobals + { + public string ReadTextFile(string path) + { + if (string.IsNullOrEmpty(path)) return ""; + try + { + if (!File.Exists(path)) return ""; + return File.ReadAllText(path); + } + catch (Exception ex) + { + Log.Error(ex, "[BlockScriptGlobals] ReadTextFile failed for {Path}", path); + return ""; + } + } + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/RunWorkflowFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/RunWorkflowFunction.cs new file mode 100644 index 00000000..e35f21bb --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/RunWorkflowFunction.cs @@ -0,0 +1,89 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using KitX.Core.Contract.Workflow; +using KitX.Core.DI; +using KitX.Core.Workflow.Blueprint; +using KitX.Core.Workflow.Blueprint.CFG; +using KitX.Core.Workflow.Blueprint.Pipeline; +using Serilog; + +namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions +{ + public class RunWorkflowFunction : IBuiltinFunctionDefinition + { + public string FunctionName => "RunWorkflow"; + public string DisplayName => "Run Workflow"; + public bool IsFlowControl => false; + public bool IsNonExtractable => true; + public BlueprintNodeType? LegacyNodeType => null; + public CFGStatementKind StatementKind => CFGStatementKind.Expression; + public double NodeWidth => 140; + public double NodeHeight => 60; + + public IReadOnlyList InputPins => [ + new("Exec", PinType.Execution, 20), + new("WorkflowId", PinType.String, 35) + ]; + + public IReadOnlyList OutputPins => [ + new("Exec", PinType.Execution, 20), + new("Return", PinType.Boolean, 40) + ]; + + public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; + + public List FormatInvocation( + InvocationExpressionSyntax invoke, string blockName, + PipelineContext context, string? assignedVar) + { + var args = invoke.ArgumentList.Arguments.Select(a => a.Expression.ToString()).ToList(); + return [new FormattedStatement + { + BlockName = blockName, + Kind = CFGStatementKind.Expression, + FunctionName = FunctionName, + Arguments = args, + OriginalExpression = invoke.ToString(), + SourceLine = 0, + }]; + } + + public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) => node; + + public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) + { + var value = helper.GetInputValue(node, "WorkflowId"); + return new ExpressionStatement + { + Expression = $"{FunctionName}({value})", + SourceCode = $"{FunctionName}({value});", + LineNumber = 1 + }; + } + + public IEnumerable GetOutputArms() => []; + } +} + +namespace KitX.Core.Workflow.BlockScripting +{ + public partial class BlockScriptExecutionGlobals + { + public bool RunWorkflow(string workflowId) + { + if (string.IsNullOrEmpty(workflowId)) return false; + if (!DI.ServiceHost.IsInitialized) return false; + try + { + var wfService = DI.ServiceHost.GetRequiredService(); + return wfService.RunWorkflowAsync(workflowId).GetAwaiter().GetResult(); + } + catch (Exception ex) + { + Log.Error(ex, "[BlockScriptGlobals] RunWorkflow failed for {Id}", workflowId); + return false; + } + } + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/StartPluginFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/StartPluginFunction.cs new file mode 100644 index 00000000..5ac59151 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/StartPluginFunction.cs @@ -0,0 +1,93 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using KitX.Core.Contract.Plugin; +using KitX.Core.Contract.Workflow; +using KitX.Core.DI; +using KitX.Core.Workflow.Blueprint; +using KitX.Core.Workflow.Blueprint.CFG; +using KitX.Core.Workflow.Blueprint.Pipeline; +using Serilog; + +namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions +{ + public class StartPluginFunction : IBuiltinFunctionDefinition + { + public string FunctionName => "StartPlugin"; + public string DisplayName => "Start Plugin"; + public bool IsFlowControl => false; + public bool IsNonExtractable => true; + public BlueprintNodeType? LegacyNodeType => null; + public CFGStatementKind StatementKind => CFGStatementKind.Expression; + public double NodeWidth => 140; + public double NodeHeight => 60; + + public IReadOnlyList InputPins => [ + new("Exec", PinType.Execution, 20), + new("PluginName", PinType.String, 35) + ]; + + public IReadOnlyList OutputPins => [ + new("Exec", PinType.Execution, 20), + new("Return", PinType.Boolean, 40) + ]; + + public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; + + public List FormatInvocation( + InvocationExpressionSyntax invoke, string blockName, + PipelineContext context, string? assignedVar) + { + var args = invoke.ArgumentList.Arguments.Select(a => a.Expression.ToString()).ToList(); + return [new FormattedStatement + { + BlockName = blockName, + Kind = CFGStatementKind.Expression, + FunctionName = FunctionName, + Arguments = args, + OriginalExpression = invoke.ToString(), + SourceLine = 0, + }]; + } + + public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) => node; + + public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) + { + var value = helper.GetInputValue(node, "PluginName"); + return new ExpressionStatement + { + Expression = $"{FunctionName}({value})", + SourceCode = $"{FunctionName}({value});", + LineNumber = 1 + }; + } + + public IEnumerable GetOutputArms() => []; + } +} + +namespace KitX.Core.Workflow.BlockScripting +{ + public partial class BlockScriptExecutionGlobals + { + public bool StartPlugin(string pluginName) + { + if (string.IsNullOrEmpty(pluginName)) return false; + if (!DI.ServiceHost.IsInitialized) return false; + try + { + var pluginService = DI.ServiceHost.GetRequiredService(); + var plugin = pluginService.GetInstalledPlugins() + .FirstOrDefault(p => p.PluginInfo?.Name == pluginName); + if (plugin == null) return false; + return pluginService.StartPluginAsync(plugin.Id).GetAwaiter().GetResult(); + } + catch (Exception ex) + { + Log.Error(ex, "[BlockScriptGlobals] StartPlugin failed for {Name}", pluginName); + return false; + } + } + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/StopPluginFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/StopPluginFunction.cs new file mode 100644 index 00000000..610b9d8c --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/StopPluginFunction.cs @@ -0,0 +1,93 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using KitX.Core.Contract.Plugin; +using KitX.Core.Contract.Workflow; +using KitX.Core.DI; +using KitX.Core.Workflow.Blueprint; +using KitX.Core.Workflow.Blueprint.CFG; +using KitX.Core.Workflow.Blueprint.Pipeline; +using Serilog; + +namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions +{ + public class StopPluginFunction : IBuiltinFunctionDefinition + { + public string FunctionName => "StopPlugin"; + public string DisplayName => "Stop Plugin"; + public bool IsFlowControl => false; + public bool IsNonExtractable => true; + public BlueprintNodeType? LegacyNodeType => null; + public CFGStatementKind StatementKind => CFGStatementKind.Expression; + public double NodeWidth => 140; + public double NodeHeight => 60; + + public IReadOnlyList InputPins => [ + new("Exec", PinType.Execution, 20), + new("PluginName", PinType.String, 35) + ]; + + public IReadOnlyList OutputPins => [ + new("Exec", PinType.Execution, 20), + new("Return", PinType.Boolean, 40) + ]; + + public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; + + public List FormatInvocation( + InvocationExpressionSyntax invoke, string blockName, + PipelineContext context, string? assignedVar) + { + var args = invoke.ArgumentList.Arguments.Select(a => a.Expression.ToString()).ToList(); + return [new FormattedStatement + { + BlockName = blockName, + Kind = CFGStatementKind.Expression, + FunctionName = FunctionName, + Arguments = args, + OriginalExpression = invoke.ToString(), + SourceLine = 0, + }]; + } + + public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) => node; + + public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) + { + var value = helper.GetInputValue(node, "PluginName"); + return new ExpressionStatement + { + Expression = $"{FunctionName}({value})", + SourceCode = $"{FunctionName}({value});", + LineNumber = 1 + }; + } + + public IEnumerable GetOutputArms() => []; + } +} + +namespace KitX.Core.Workflow.BlockScripting +{ + public partial class BlockScriptExecutionGlobals + { + public bool StopPlugin(string pluginName) + { + if (string.IsNullOrEmpty(pluginName)) return false; + if (!DI.ServiceHost.IsInitialized) return false; + try + { + var pluginService = DI.ServiceHost.GetRequiredService(); + var plugin = pluginService.GetInstalledPlugins() + .FirstOrDefault(p => p.PluginInfo?.Name == pluginName); + if (plugin == null) return false; + return pluginService.StopPluginAsync(plugin.Id).GetAwaiter().GetResult(); + } + catch (Exception ex) + { + Log.Error(ex, "[BlockScriptGlobals] StopPlugin failed for {Name}", pluginName); + return false; + } + } + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/StopWorkflowFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/StopWorkflowFunction.cs new file mode 100644 index 00000000..8dcb0abd --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/StopWorkflowFunction.cs @@ -0,0 +1,89 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using KitX.Core.Contract.Workflow; +using KitX.Core.DI; +using KitX.Core.Workflow.Blueprint; +using KitX.Core.Workflow.Blueprint.CFG; +using KitX.Core.Workflow.Blueprint.Pipeline; +using Serilog; + +namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions +{ + public class StopWorkflowFunction : IBuiltinFunctionDefinition + { + public string FunctionName => "StopWorkflow"; + public string DisplayName => "Stop Workflow"; + public bool IsFlowControl => false; + public bool IsNonExtractable => true; + public BlueprintNodeType? LegacyNodeType => null; + public CFGStatementKind StatementKind => CFGStatementKind.Expression; + public double NodeWidth => 140; + public double NodeHeight => 60; + + public IReadOnlyList InputPins => [ + new("Exec", PinType.Execution, 20), + new("WorkflowId", PinType.String, 35) + ]; + + public IReadOnlyList OutputPins => [ + new("Exec", PinType.Execution, 20), + new("Return", PinType.Boolean, 40) + ]; + + public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; + + public List FormatInvocation( + InvocationExpressionSyntax invoke, string blockName, + PipelineContext context, string? assignedVar) + { + var args = invoke.ArgumentList.Arguments.Select(a => a.Expression.ToString()).ToList(); + return [new FormattedStatement + { + BlockName = blockName, + Kind = CFGStatementKind.Expression, + FunctionName = FunctionName, + Arguments = args, + OriginalExpression = invoke.ToString(), + SourceLine = 0, + }]; + } + + public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) => node; + + public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) + { + var value = helper.GetInputValue(node, "WorkflowId"); + return new ExpressionStatement + { + Expression = $"{FunctionName}({value})", + SourceCode = $"{FunctionName}({value});", + LineNumber = 1 + }; + } + + public IEnumerable GetOutputArms() => []; + } +} + +namespace KitX.Core.Workflow.BlockScripting +{ + public partial class BlockScriptExecutionGlobals + { + public bool StopWorkflow(string workflowId) + { + if (string.IsNullOrEmpty(workflowId)) return false; + if (!DI.ServiceHost.IsInitialized) return false; + try + { + var wfService = DI.ServiceHost.GetRequiredService(); + return wfService.StopWorkflowAsync(workflowId).GetAwaiter().GetResult(); + } + catch (Exception ex) + { + Log.Error(ex, "[BlockScriptGlobals] StopWorkflow failed for {Id}", workflowId); + return false; + } + } + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/WriteTextFileFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/WriteTextFileFunction.cs new file mode 100644 index 00000000..8f149bc3 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/WriteTextFileFunction.cs @@ -0,0 +1,89 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using KitX.Core.Contract.Workflow; +using KitX.Core.Workflow.Blueprint; +using KitX.Core.Workflow.Blueprint.CFG; +using KitX.Core.Workflow.Blueprint.Pipeline; +using Serilog; + +namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions +{ + public class WriteTextFileFunction : IBuiltinFunctionDefinition + { + public string FunctionName => "WriteTextFile"; + public string DisplayName => "Write Text File"; + public bool IsFlowControl => false; + public bool IsNonExtractable => true; + public BlueprintNodeType? LegacyNodeType => null; + public CFGStatementKind StatementKind => CFGStatementKind.Expression; + public double NodeWidth => 160; + public double NodeHeight => 80; + + public IReadOnlyList InputPins => [ + new("Exec", PinType.Execution, 20), + new("Path", PinType.String, 35), + new("Content", PinType.String, 35) + ]; + + public IReadOnlyList OutputPins => [ + new("Exec", PinType.Execution, 20) + ]; + + public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; + + public List FormatInvocation( + InvocationExpressionSyntax invoke, string blockName, + PipelineContext context, string? assignedVar) + { + var args = invoke.ArgumentList.Arguments.Select(a => a.Expression.ToString()).ToList(); + return [new FormattedStatement + { + BlockName = blockName, + Kind = CFGStatementKind.Expression, + FunctionName = FunctionName, + Arguments = args, + OriginalExpression = invoke.ToString(), + SourceLine = 0, + }]; + } + + public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) => node; + + public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) + { + var path = helper.GetInputValue(node, "Path"); + var content = helper.GetInputValue(node, "Content"); + return new ExpressionStatement + { + Expression = $"{FunctionName}({path}, {content})", + SourceCode = $"{FunctionName}({path}, {content});", + LineNumber = 1 + }; + } + + public IEnumerable GetOutputArms() => []; + } +} + +namespace KitX.Core.Workflow.BlockScripting +{ + public partial class BlockScriptExecutionGlobals + { + public void WriteTextFile(string path, string content) + { + if (string.IsNullOrEmpty(path)) return; + try + { + var dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + Directory.CreateDirectory(dir); + File.WriteAllText(path, content); + } + catch (Exception ex) + { + Log.Error(ex, "[BlockScriptGlobals] WriteTextFile failed for {Path}", path); + } + } + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptCodeGenerator.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptCodeGenerator.cs index 5010aa9b..b80b618c 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptCodeGenerator.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptCodeGenerator.cs @@ -26,6 +26,9 @@ namespace KitX.Core.Workflow.BlockScripting; /// internal static class ScriptCodeGenerator { + private static readonly BuiltinFunctionRegistry FunctionRegistry = + BuiltinFunctionRegistry.Discover(typeof(ScriptCodeGenerator).Assembly); + // ────────────────────────────────────────────── // Type inference // ────────────────────────────────────────────── @@ -543,6 +546,11 @@ internal static SwitchSectionSyntax GenerateFormattedBlockCase( { rawExpr = BuildPluginCallExpression(stmt, pubVarTypes); } + else if (FunctionRegistry.AllFunctionNames.Contains(stmt.FunctionName ?? "")) + { + rawExpr = ParseExpression( + $"G.{stmt.FunctionName}({string.Join(", ", stmt.Arguments)})"); + } else { rawExpr = ParseExpression( From fd8b83fb514ec4bf8208d768500b7655d67ab376 Mon Sep 17 00:00:00 2001 From: StarInk Date: Tue, 5 May 2026 18:16:37 +0200 Subject: [PATCH 066/110] =?UTF-8?q?=F0=9F=A7=A9=20Refactor(Workflow):=20?= =?UTF-8?q?=E6=AD=A3=E5=8F=8D=E8=BD=AC=E6=8D=A2=E7=BB=9F=E4=B8=80=E4=BD=BF?= =?UTF-8?q?=E7=94=A8CFG=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BuiltinFunctions/BranchFunction.cs | 6 +- .../BuiltinFunctions/BreakFunction.cs | 6 +- .../CreateWorkflowFunction.cs | 6 +- .../BuiltinFunctions/FlipFunction.cs | 8 +- .../BuiltinFunctions/GetFunction.cs | 6 +- .../GetPluginInfoByNameFunction.cs | 6 +- .../BuiltinFunctions/InstallPluginFunction.cs | 6 +- .../BuiltinFunctions/JsonGetFieldFunction.cs | 6 +- .../ListPluginNamesFunction.cs | 6 +- .../BuiltinFunctions/ListWorkflowsFunction.cs | 6 +- .../BuiltinFunctions/LoopFunction.cs | 6 +- .../BuiltinFunctions/PauseFunction.cs | 6 +- .../BuiltinFunctions/PluginCallFunction.cs | 6 +- .../PluginCallWithTargetFunction.cs | 6 +- .../BuiltinFunctions/PrintFunction.cs | 6 +- .../BuiltinFunctions/ReadTextFileFunction.cs | 6 +- .../BuiltinFunctions/RunWorkflowFunction.cs | 6 +- .../BuiltinFunctions/SetFunction.cs | 6 +- .../BuiltinFunctions/StartPluginFunction.cs | 6 +- .../BuiltinFunctions/StopPluginFunction.cs | 6 +- .../BuiltinFunctions/StopWorkflowFunction.cs | 6 +- .../BuiltinFunctions/ToLoopCondFunction.cs | 6 +- .../BuiltinFunctions/TryGetDeviceFunction.cs | 6 +- .../BuiltinFunctions/WriteTextFileFunction.cs | 6 +- .../IBuiltinFunctionDefinition.cs | 12 +- .../BlockScripting/ScriptAssemblyCompiler.cs | 3 +- .../BlockScripting/ScriptCodeGenerator.cs | 217 ++++++++---------- .../Workflow/Blueprint/CFG/CFGBlock.cs | 4 +- .../Blueprint/CFG/CFGBuilderFromBlueprint.cs | 10 +- .../Blueprint/CFG/CFGBuilderFromScript.cs | 14 +- .../Blueprint/CFG/CFGConditionDuplicator.cs | 2 +- .../Workflow/Blueprint/CFG/CFGEdge.cs | 4 +- .../Workflow/Blueprint/CFG/CFGStatement.cs | 10 +- .../Blueprint/CFG/ConstDeclaration.cs | 2 +- .../Blueprint/CFG/ControlFlowGraph.cs | 7 +- .../Blueprint/Pipeline/DataEdgeBuilder.cs | 14 +- .../Pipeline/FormattedBlockScript.cs | 108 --------- .../Blueprint/Pipeline/NodeBuilder.cs | 18 +- .../Blueprint/Pipeline/PipelineContext.cs | 5 +- .../Blueprint/Pipeline/ScriptFormatter.cs | 48 ++-- 40 files changed, 246 insertions(+), 378 deletions(-) delete mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/FormattedBlockScript.cs diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/BranchFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/BranchFunction.cs index 7c7f404d..e7de6a7b 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/BranchFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/BranchFunction.cs @@ -49,16 +49,16 @@ public class BranchFunction : IBuiltinFunctionDefinition return stmt; } - public List FormatInvocation( + public List FormatInvocation( InvocationExpressionSyntax invoke, string blockName, PipelineContext context, string? assignedVar) { return []; } - public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) => node; + public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) => node; - public void OnNodeCreated(BlueprintNode node, FormattedStatement stmt, PipelineContext context) + public void OnNodeCreated(BlueprintNode node, CFGStatement stmt, PipelineContext context) { if (!string.IsNullOrEmpty(stmt.TrueBlockName) || !string.IsNullOrEmpty(stmt.FalseBlockName)) { diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/BreakFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/BreakFunction.cs index 4089bd47..cbefb379 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/BreakFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/BreakFunction.cs @@ -31,11 +31,11 @@ public class BreakFunction : IBuiltinFunctionDefinition public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; - public List FormatInvocation( + public List FormatInvocation( InvocationExpressionSyntax invoke, string blockName, PipelineContext context, string? assignedVar) { - return [new FormattedStatement + return [new CFGStatement { BlockName = blockName, Kind = CFGStatementKind.Break, @@ -45,7 +45,7 @@ public List FormatInvocation( }]; } - public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) => node; + public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) => node; public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) { diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/CreateWorkflowFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/CreateWorkflowFunction.cs index 055ba455..8594fffa 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/CreateWorkflowFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/CreateWorkflowFunction.cs @@ -34,12 +34,12 @@ public class CreateWorkflowFunction : IBuiltinFunctionDefinition public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; - public List FormatInvocation( + public List FormatInvocation( InvocationExpressionSyntax invoke, string blockName, PipelineContext context, string? assignedVar) { var args = invoke.ArgumentList.Arguments.Select(a => a.Expression.ToString()).ToList(); - return [new FormattedStatement + return [new CFGStatement { BlockName = blockName, Kind = CFGStatementKind.Expression, @@ -50,7 +50,7 @@ public List FormatInvocation( }]; } - public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) + public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) { if (node is BuiltinFunctionNode bfn && stmt.Arguments?.Count > 0) bfn.Properties["Name"] = stmt.Arguments[0]; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/FlipFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/FlipFunction.cs index cbf872aa..2865a2d0 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/FlipFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/FlipFunction.cs @@ -49,7 +49,7 @@ public class FlipFunction : IBuiltinFunctionDefinition return stmt; } - public List FormatInvocation( + public List FormatInvocation( InvocationExpressionSyntax invoke, string blockName, PipelineContext context, string? assignedVar) { @@ -57,7 +57,7 @@ public List FormatInvocation( var trueBlock = args.Count >= 1 ? GetStringLiteral(args[0].Expression) : ""; var falseBlock = args.Count >= 2 ? GetStringLiteral(args[1].Expression) : ""; - return [new FormattedStatement + return [new CFGStatement { BlockName = blockName, Kind = CFGStatementKind.Branch, // Reuse Branch kind for pipeline routing @@ -69,9 +69,9 @@ public List FormatInvocation( }]; } - public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) => node; + public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) => node; - public void OnNodeCreated(BlueprintNode node, FormattedStatement stmt, PipelineContext context) + public void OnNodeCreated(BlueprintNode node, CFGStatement stmt, PipelineContext context) { var arms = new List<(string PinName, string TargetBlockName)>(); if (!string.IsNullOrEmpty(stmt.TrueBlockName)) diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/GetFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/GetFunction.cs index 71fc53c9..fbdc7da0 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/GetFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/GetFunction.cs @@ -33,7 +33,7 @@ public class GetFunction : IBuiltinFunctionDefinition public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; - public List FormatInvocation( + public List FormatInvocation( InvocationExpressionSyntax invoke, string blockName, PipelineContext context, string? assignedVar) { @@ -60,7 +60,7 @@ public List FormatInvocation( pubVarTarget = assignedVar; } - return [new FormattedStatement + return [new CFGStatement { BlockName = blockName, Kind = CFGStatementKind.Assignment, @@ -73,7 +73,7 @@ public List FormatInvocation( }]; } - public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) + public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) { var varName = stmt.GetVarName ?? (stmt.Arguments?.Count > 0 ? stmt.Arguments[0].Trim('"') : ""); if (node is GetNode gn) diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/GetPluginInfoByNameFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/GetPluginInfoByNameFunction.cs index 01cd91e8..c718e13a 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/GetPluginInfoByNameFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/GetPluginInfoByNameFunction.cs @@ -35,12 +35,12 @@ public class GetPluginInfoByNameFunction : IBuiltinFunctionDefinition public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; - public List FormatInvocation( + public List FormatInvocation( InvocationExpressionSyntax invoke, string blockName, PipelineContext context, string? assignedVar) { var args = invoke.ArgumentList.Arguments.Select(a => a.Expression.ToString()).ToList(); - return [new FormattedStatement + return [new CFGStatement { BlockName = blockName, Kind = CFGStatementKind.Expression, @@ -51,7 +51,7 @@ public List FormatInvocation( }]; } - public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) + public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) { if (node is BuiltinFunctionNode bfn && stmt.Arguments?.Count > 0) bfn.Properties["PluginName"] = StripQuotes(stmt.Arguments[0]); diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/InstallPluginFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/InstallPluginFunction.cs index 7581c930..08fc7585 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/InstallPluginFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/InstallPluginFunction.cs @@ -34,12 +34,12 @@ public class InstallPluginFunction : IBuiltinFunctionDefinition public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; - public List FormatInvocation( + public List FormatInvocation( InvocationExpressionSyntax invoke, string blockName, PipelineContext context, string? assignedVar) { var args = invoke.ArgumentList.Arguments.Select(a => a.Expression.ToString()).ToList(); - return [new FormattedStatement + return [new CFGStatement { BlockName = blockName, Kind = CFGStatementKind.Expression, @@ -50,7 +50,7 @@ public List FormatInvocation( }]; } - public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) => node; + public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) => node; public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) { diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/JsonGetFieldFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/JsonGetFieldFunction.cs index 20b7cb9d..8afa0eef 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/JsonGetFieldFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/JsonGetFieldFunction.cs @@ -34,12 +34,12 @@ public class JsonGetFieldFunction : IBuiltinFunctionDefinition public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; - public List FormatInvocation( + public List FormatInvocation( InvocationExpressionSyntax invoke, string blockName, PipelineContext context, string? assignedVar) { var args = invoke.ArgumentList.Arguments.Select(a => a.Expression.ToString()).ToList(); - return [new FormattedStatement + return [new CFGStatement { BlockName = blockName, Kind = CFGStatementKind.Expression, @@ -50,7 +50,7 @@ public List FormatInvocation( }]; } - public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) + public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) { if (node is BuiltinFunctionNode bfn && stmt.Arguments?.Count > 1) bfn.Properties["FieldPath"] = StripQuotes(stmt.Arguments[1]); diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/ListPluginNamesFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/ListPluginNamesFunction.cs index 48cce985..1050c397 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/ListPluginNamesFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/ListPluginNamesFunction.cs @@ -34,11 +34,11 @@ public class ListPluginNamesFunction : IBuiltinFunctionDefinition public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; - public List FormatInvocation( + public List FormatInvocation( InvocationExpressionSyntax invoke, string blockName, PipelineContext context, string? assignedVar) { - return [new FormattedStatement + return [new CFGStatement { BlockName = blockName, Kind = CFGStatementKind.Expression, @@ -49,7 +49,7 @@ public List FormatInvocation( }]; } - public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) => node; + public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) => node; public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) { diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/ListWorkflowsFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/ListWorkflowsFunction.cs index e468fdc6..73d1d95f 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/ListWorkflowsFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/ListWorkflowsFunction.cs @@ -33,11 +33,11 @@ public class ListWorkflowsFunction : IBuiltinFunctionDefinition public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; - public List FormatInvocation( + public List FormatInvocation( InvocationExpressionSyntax invoke, string blockName, PipelineContext context, string? assignedVar) { - return [new FormattedStatement + return [new CFGStatement { BlockName = blockName, Kind = CFGStatementKind.Expression, @@ -48,7 +48,7 @@ public List FormatInvocation( }]; } - public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) => node; + public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) => node; public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) { diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/LoopFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/LoopFunction.cs index b677dfd2..e39d31db 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/LoopFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/LoopFunction.cs @@ -50,16 +50,16 @@ public class LoopFunction : IBuiltinFunctionDefinition return stmt; } - public List FormatInvocation( + public List FormatInvocation( InvocationExpressionSyntax invoke, string blockName, PipelineContext context, string? assignedVar) { return []; } - public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) => node; + public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) => node; - public void OnNodeCreated(BlueprintNode node, FormattedStatement stmt, PipelineContext context) + public void OnNodeCreated(BlueprintNode node, CFGStatement stmt, PipelineContext context) { var arms = new List<(string PinName, string TargetBlockName)>(); if (!string.IsNullOrEmpty(stmt.TrueBlockName)) diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PauseFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PauseFunction.cs index 47718451..2feeb12f 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PauseFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PauseFunction.cs @@ -33,12 +33,12 @@ public class PauseFunction : IBuiltinFunctionDefinition public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; - public List FormatInvocation( + public List FormatInvocation( InvocationExpressionSyntax invoke, string blockName, PipelineContext context, string? assignedVar) { var args = invoke.ArgumentList.Arguments.Select(a => a.Expression.ToString()).ToList(); - return [new FormattedStatement + return [new CFGStatement { BlockName = blockName, Kind = CFGStatementKind.Pause, @@ -49,7 +49,7 @@ public List FormatInvocation( }]; } - public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) => node; + public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) => node; public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) { diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PluginCallFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PluginCallFunction.cs index 8d43f5e1..cb9b2117 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PluginCallFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PluginCallFunction.cs @@ -38,14 +38,14 @@ public class PluginCallFunction : IBuiltinFunctionDefinition public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; - public List FormatInvocation( + public List FormatInvocation( InvocationExpressionSyntax invoke, string blockName, PipelineContext context, string? assignedVar) { var args = invoke.ArgumentList.Arguments .Select(a => a.Expression.ToString()) .ToList(); - return [new FormattedStatement + return [new CFGStatement { BlockName = blockName, Kind = CFGStatementKind.Expression, @@ -57,7 +57,7 @@ public List FormatInvocation( }]; } - public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) => node; + public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) => node; public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) => null; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PluginCallWithTargetFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PluginCallWithTargetFunction.cs index 332bf8a6..72f4f5cc 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PluginCallWithTargetFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PluginCallWithTargetFunction.cs @@ -42,7 +42,7 @@ public class PluginCallWithTargetFunction : IBuiltinFunctionDefinition public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; - public List FormatInvocation( + public List FormatInvocation( InvocationExpressionSyntax invoke, string blockName, PipelineContext context, string? assignedVar) { @@ -50,7 +50,7 @@ public List FormatInvocation( .Select(a => a.Expression.ToString()) .ToList(); - return [new FormattedStatement + return [new CFGStatement { BlockName = blockName, Kind = CFGStatementKind.PluginCallWithTarget, @@ -62,7 +62,7 @@ public List FormatInvocation( }]; } - public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) + public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) { if (node is CallNode call) { diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PrintFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PrintFunction.cs index 2d22cbb3..98ac7dc8 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PrintFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PrintFunction.cs @@ -32,12 +32,12 @@ public class PrintFunction : IBuiltinFunctionDefinition public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; - public List FormatInvocation( + public List FormatInvocation( InvocationExpressionSyntax invoke, string blockName, PipelineContext context, string? assignedVar) { var args = invoke.ArgumentList.Arguments.Select(a => a.Expression.ToString()).ToList(); - return [new FormattedStatement + return [new CFGStatement { BlockName = blockName, Kind = CFGStatementKind.Print, @@ -48,7 +48,7 @@ public List FormatInvocation( }]; } - public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) => node; + public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) => node; public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) { diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/ReadTextFileFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/ReadTextFileFunction.cs index ab66fd2a..0f89d567 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/ReadTextFileFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/ReadTextFileFunction.cs @@ -32,12 +32,12 @@ public class ReadTextFileFunction : IBuiltinFunctionDefinition public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; - public List FormatInvocation( + public List FormatInvocation( InvocationExpressionSyntax invoke, string blockName, PipelineContext context, string? assignedVar) { var args = invoke.ArgumentList.Arguments.Select(a => a.Expression.ToString()).ToList(); - return [new FormattedStatement + return [new CFGStatement { BlockName = blockName, Kind = CFGStatementKind.Expression, @@ -48,7 +48,7 @@ public List FormatInvocation( }]; } - public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) => node; + public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) => node; public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) { diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/RunWorkflowFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/RunWorkflowFunction.cs index e35f21bb..d7e0ef25 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/RunWorkflowFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/RunWorkflowFunction.cs @@ -33,12 +33,12 @@ public class RunWorkflowFunction : IBuiltinFunctionDefinition public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; - public List FormatInvocation( + public List FormatInvocation( InvocationExpressionSyntax invoke, string blockName, PipelineContext context, string? assignedVar) { var args = invoke.ArgumentList.Arguments.Select(a => a.Expression.ToString()).ToList(); - return [new FormattedStatement + return [new CFGStatement { BlockName = blockName, Kind = CFGStatementKind.Expression, @@ -49,7 +49,7 @@ public List FormatInvocation( }]; } - public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) => node; + public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) => node; public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) { diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/SetFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/SetFunction.cs index d91f0a79..27fe9dc8 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/SetFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/SetFunction.cs @@ -33,7 +33,7 @@ public class SetFunction : IBuiltinFunctionDefinition public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; - public List FormatInvocation( + public List FormatInvocation( InvocationExpressionSyntax invoke, string blockName, PipelineContext context, string? assignedVar) { @@ -48,7 +48,7 @@ public List FormatInvocation( currentArgExprs.RemoveAt(0); } - return [new FormattedStatement + return [new CFGStatement { BlockName = blockName, Kind = CFGStatementKind.Set, @@ -60,7 +60,7 @@ public List FormatInvocation( }]; } - public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) + public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) { var varName = stmt.SetVarName ?? ""; if (node is SetNode sn) diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/StartPluginFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/StartPluginFunction.cs index 5ac59151..d314b39e 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/StartPluginFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/StartPluginFunction.cs @@ -34,12 +34,12 @@ public class StartPluginFunction : IBuiltinFunctionDefinition public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; - public List FormatInvocation( + public List FormatInvocation( InvocationExpressionSyntax invoke, string blockName, PipelineContext context, string? assignedVar) { var args = invoke.ArgumentList.Arguments.Select(a => a.Expression.ToString()).ToList(); - return [new FormattedStatement + return [new CFGStatement { BlockName = blockName, Kind = CFGStatementKind.Expression, @@ -50,7 +50,7 @@ public List FormatInvocation( }]; } - public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) => node; + public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) => node; public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) { diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/StopPluginFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/StopPluginFunction.cs index 610b9d8c..b0951db9 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/StopPluginFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/StopPluginFunction.cs @@ -34,12 +34,12 @@ public class StopPluginFunction : IBuiltinFunctionDefinition public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; - public List FormatInvocation( + public List FormatInvocation( InvocationExpressionSyntax invoke, string blockName, PipelineContext context, string? assignedVar) { var args = invoke.ArgumentList.Arguments.Select(a => a.Expression.ToString()).ToList(); - return [new FormattedStatement + return [new CFGStatement { BlockName = blockName, Kind = CFGStatementKind.Expression, @@ -50,7 +50,7 @@ public List FormatInvocation( }]; } - public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) => node; + public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) => node; public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) { diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/StopWorkflowFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/StopWorkflowFunction.cs index 8dcb0abd..c7596e13 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/StopWorkflowFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/StopWorkflowFunction.cs @@ -33,12 +33,12 @@ public class StopWorkflowFunction : IBuiltinFunctionDefinition public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; - public List FormatInvocation( + public List FormatInvocation( InvocationExpressionSyntax invoke, string blockName, PipelineContext context, string? assignedVar) { var args = invoke.ArgumentList.Arguments.Select(a => a.Expression.ToString()).ToList(); - return [new FormattedStatement + return [new CFGStatement { BlockName = blockName, Kind = CFGStatementKind.Expression, @@ -49,7 +49,7 @@ public List FormatInvocation( }]; } - public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) => node; + public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) => node; public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) { diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/ToLoopCondFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/ToLoopCondFunction.cs index 8324a8f1..5e31cd8a 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/ToLoopCondFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/ToLoopCondFunction.cs @@ -49,7 +49,7 @@ public class ToLoopCondFunction : IBuiltinFunctionDefinition return stmt; } - public List FormatInvocation( + public List FormatInvocation( InvocationExpressionSyntax invoke, string blockName, PipelineContext context, string? assignedVar) { @@ -57,9 +57,9 @@ public List FormatInvocation( return []; } - public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) => node; + public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) => node; - public void OnNodeCreated(BlueprintNode node, FormattedStatement stmt, PipelineContext context) + public void OnNodeCreated(BlueprintNode node, CFGStatement stmt, PipelineContext context) { var returnToBlock = stmt.ToLoopCondReturnTo; if (!string.IsNullOrEmpty(returnToBlock)) diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/TryGetDeviceFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/TryGetDeviceFunction.cs index e27eec4c..9c4abc03 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/TryGetDeviceFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/TryGetDeviceFunction.cs @@ -44,7 +44,7 @@ public class TryGetDeviceFunction : IBuiltinFunctionDefinition public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; - public List FormatInvocation( + public List FormatInvocation( InvocationExpressionSyntax invoke, string blockName, PipelineContext context, string? assignedVar) { @@ -64,7 +64,7 @@ public List FormatInvocation( context.PubVarNames.Add(pubVarTarget); } - return [new FormattedStatement + return [new CFGStatement { BlockName = blockName, Kind = CFGStatementKind.TryGetDevice, @@ -76,7 +76,7 @@ public List FormatInvocation( }]; } - public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) + public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) { if (node is BuiltinFunctionNode bfn && stmt.Arguments?.Count > 0) { diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/WriteTextFileFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/WriteTextFileFunction.cs index 8f149bc3..26be1aba 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/WriteTextFileFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/WriteTextFileFunction.cs @@ -32,12 +32,12 @@ public class WriteTextFileFunction : IBuiltinFunctionDefinition public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; - public List FormatInvocation( + public List FormatInvocation( InvocationExpressionSyntax invoke, string blockName, PipelineContext context, string? assignedVar) { var args = invoke.ArgumentList.Arguments.Select(a => a.Expression.ToString()).ToList(); - return [new FormattedStatement + return [new CFGStatement { BlockName = blockName, Kind = CFGStatementKind.Expression, @@ -48,7 +48,7 @@ public List FormatInvocation( }]; } - public BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt) => node; + public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) => node; public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) { diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/IBuiltinFunctionDefinition.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/IBuiltinFunctionDefinition.cs index 41c3374a..2713fd79 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/IBuiltinFunctionDefinition.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/IBuiltinFunctionDefinition.cs @@ -80,25 +80,25 @@ public interface IBuiltinFunctionDefinition /// BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText); - // ─── 格式化(AST → FormattedStatement)───────── + // ─── 格式化(AST → CFGStatement)───────── /// - /// 将函数调用格式化为展开后的 FormattedStatement 列表。 + /// 将函数调用格式化为展开后的 CFGStatement 列表。 /// 嵌套参数应在此时被展开为 PubVar 赋值。 /// - List FormatInvocation( + List FormatInvocation( InvocationExpressionSyntax invoke, string blockName, PipelineContext context, string? assignedVar); - // ─── 节点构建(FormattedStatement → BlueprintNode)── + // ─── 节点构建(CFGStatement → BlueprintNode)── /// /// 对新创建的 BuiltinFunctionNode 进行额外配置(如设置 Properties 字典)。 /// 返回配置后的节点。 /// - BlueprintNode ConfigureNode(BlueprintNode node, FormattedStatement stmt); + BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt); // ─── 导出(Blueprint → BlockScript)──────────── @@ -130,5 +130,5 @@ List FormatInvocation( /// 仅在 IsFlowControl == true 且 IsBlockTerminator == true 时被调用。 /// 默认无操作。 /// - void OnNodeCreated(BlueprintNode node, FormattedStatement stmt, PipelineContext context) { } + void OnNodeCreated(BlueprintNode node, CFGStatement stmt, PipelineContext context) { } } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptAssemblyCompiler.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptAssemblyCompiler.cs index d00d4ba8..c20bae0d 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptAssemblyCompiler.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptAssemblyCompiler.cs @@ -1,3 +1,4 @@ +using KitX.Core.Workflow.Blueprint.CFG; using System; using System.Collections.Generic; using KitX.Core.Contract.Workflow; @@ -164,7 +165,7 @@ public void ClearCache() /// /// Formats the script using and infers PubVar types. /// - private (FormattedBlockScript formatted, Dictionary pubVarTypes) FormatAndInferTypes( + private (ControlFlowGraph formatted, Dictionary pubVarTypes) FormatAndInferTypes( BlockScript script) { var context = new PipelineContext { Script = script }; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptCodeGenerator.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptCodeGenerator.cs index b80b618c..bc5f35e3 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptCodeGenerator.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptCodeGenerator.cs @@ -43,7 +43,7 @@ internal static class ScriptCodeGenerator /// ConvertTo<T> is needed when SOURCE is object but DEMANDED is a specific type. /// internal static Dictionary InferPubVarTypes( - FormattedBlockScript formattedScript, + ControlFlowGraph formattedScript, List? helperFunctions, PipelineContext context) { @@ -134,7 +134,7 @@ internal static Dictionary InferPubVarTypes( /// internal static CompilationUnitSyntax GenerateCompilationUnit( BlockScript script, - FormattedBlockScript formattedScript, + ControlFlowGraph formattedScript, Dictionary pubVarTypes, string hash) { @@ -278,7 +278,7 @@ internal static List ParseHelperFunctionBody(string? code) /// internal static MethodDeclarationSyntax GenerateRunMethod( BlockScript script, - FormattedBlockScript formattedScript, + ControlFlowGraph formattedScript, Dictionary pubVarTypes) { var statements = new List(); @@ -471,7 +471,7 @@ internal static ExpressionSyntax FormatLiteralExpression(string type, object val /// Generates for each formatted block. /// internal static List GenerateSwitchSections( - FormattedBlockScript formattedScript, + ControlFlowGraph formattedScript, Dictionary pubVarTypes, List? helperFunctions) { @@ -493,10 +493,10 @@ internal static List GenerateSwitchSections( } /// - /// Generates a single switch case from a . + /// Generates a single switch case from a . /// internal static SwitchSectionSyntax GenerateFormattedBlockCase( - FormattedBlock block, + CFGBlock block, Dictionary pubVarTypes, List? helperFunctions, Dictionary helperReturnTypes) @@ -579,103 +579,31 @@ internal static SwitchSectionSyntax GenerateFormattedBlockCase( case CFGStatementKind.Branch: { - var condExpr = !string.IsNullOrEmpty(stmt.ConditionPubVar) - ? ResolveArgumentExpression(stmt.ConditionPubVar, pubVarTypes) - : ParseExpression(stmt.ConditionExpression ?? "false"); - - caseStatements.Add(ExpressionStatement( - AssignmentExpression(SyntaxKind.SimpleAssignmentExpression, - MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, - IdentifierName("G"), IdentifierName("NextBlock")), - InvocationExpression( - MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, - IdentifierName("G"), IdentifierName("Branch")), - ArgumentList(SeparatedList(new[] - { - Argument(condExpr), - Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, - Literal(stmt.TrueBlockName ?? ""))), - Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, - Literal(stmt.FalseBlockName ?? ""))) - })))))); - - hasNextBlockAssignment = true; - caseStatements.Add(BreakStatement()); + GenerateFlowControl("Branch", stmt, caseStatements, pubVarTypes, + ref hasNextBlockAssignment, + conditionPubVar: stmt.ConditionPubVar, + trueBlockName: stmt.TrueBlockName, falseBlockName: stmt.FalseBlockName); break; } case CFGStatementKind.Loop: { - var condExpr = !string.IsNullOrEmpty(stmt.ConditionPubVar) - ? ResolveArgumentExpression(stmt.ConditionPubVar, pubVarTypes) - : ParseExpression(stmt.ConditionExpression ?? "false"); - - caseStatements.Add(ExpressionStatement( - AssignmentExpression(SyntaxKind.SimpleAssignmentExpression, - MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, - IdentifierName("G"), IdentifierName("NextBlock")), - InvocationExpression( - MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, - IdentifierName("G"), IdentifierName("Loop")), - ArgumentList(SeparatedList(new[] - { - Argument(condExpr), - Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, - Literal(stmt.TrueBlockName ?? ""))), - Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, - Literal(stmt.FalseBlockName ?? ""))) - })))))); - - hasNextBlockAssignment = true; - caseStatements.Add(BreakStatement()); + GenerateFlowControl("Loop", stmt, caseStatements, pubVarTypes, + ref hasNextBlockAssignment, + conditionPubVar: stmt.ConditionPubVar, + trueBlockName: stmt.TrueBlockName, falseBlockName: stmt.FalseBlockName); break; } case CFGStatementKind.Print: { - if (stmt.Arguments.Count > 0) - { - var argExpr = ResolveArgumentExpression(stmt.Arguments[0], pubVarTypes); - caseStatements.Add( - ExpressionStatement( - InvocationExpression( - MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, - IdentifierName("G"), IdentifierName("Print")), - ArgumentList(SeparatedList(new[] { Argument(argExpr) }))))); - } - + GenerateSimpleMethodCall("Print", stmt, caseStatements, pubVarTypes); break; } - case CFGStatementKind.PluginCallWithTarget: + case CFGStatementKind.Pause: { - var args = new List(); - - if (stmt.Arguments.Count > 0) - args.Add(Argument(ResolveArgumentExpression(stmt.Arguments[0], pubVarTypes))); - else - args.Add(Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, Literal("")))); - - if (stmt.Arguments.Count > 1) - args.Add(Argument(ResolveArgumentExpression(stmt.Arguments[1], pubVarTypes))); - else - args.Add(Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, Literal("")))); - - if (stmt.Arguments.Count > 2) - args.Add(Argument(ResolveArgumentExpression(stmt.Arguments[2], pubVarTypes))); - else - args.Add(Argument(LiteralExpression(SyntaxKind.NullLiteralExpression))); - - for (int i = 3; i < stmt.Arguments.Count; i++) - args.Add(Argument(ResolveArgumentExpression(stmt.Arguments[i], pubVarTypes))); - - caseStatements.Add( - ExpressionStatement( - InvocationExpression( - MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, - IdentifierName("G"), IdentifierName("PluginCallWithTarget")), - ArgumentList(SeparatedList(args))))); - + GenerateSimpleMethodCall("Pause", stmt, caseStatements, pubVarTypes); break; } @@ -723,44 +651,33 @@ internal static SwitchSectionSyntax GenerateFormattedBlockCase( case CFGStatementKind.ToLoopCond: { - caseStatements.Add( - ExpressionStatement( - AssignmentExpression(SyntaxKind.SimpleAssignmentExpression, - MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, - IdentifierName("G"), IdentifierName("NextBlock")), - InvocationExpression( - MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, - IdentifierName("G"), IdentifierName("ToLoopCond")), - ArgumentList(SeparatedList(new[] - { - Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, - Literal(stmt.ToLoopCondReturnTo ?? ""))) - })))))); - - hasNextBlockAssignment = true; - caseStatements.Add(BreakStatement()); + GenerateFlowControl("ToLoopCond", stmt, caseStatements, pubVarTypes, + ref hasNextBlockAssignment, + returnToBlock: stmt.ToLoopCondReturnTo); break; } - case CFGStatementKind.Break: + case CFGStatementKind.PluginCallWithTarget: { - caseStatements.Add(ReturnStatement()); + var callExpr = BuildPluginCallWithTargetExpression(stmt, pubVarTypes); + if (stmt.PubVarTarget != null) + { + var typeName = pubVarTypes.GetValueOrDefault(stmt.PubVarTarget, "object"); + caseStatements.Add(LocalDeclarationStatement( + VariableDeclaration(ParseTypeName(typeName)) + .AddVariables(VariableDeclarator(Identifier(stmt.PubVarTarget)) + .WithInitializer(EqualsValueClause(callExpr))))); + } + else + { + caseStatements.Add(ExpressionStatement(callExpr)); + } break; } - case CFGStatementKind.Pause: + case CFGStatementKind.Break: { - if (stmt.Arguments.Count > 0) - { - var msExpr = ResolveArgumentExpression(stmt.Arguments[0], pubVarTypes); - caseStatements.Add( - ExpressionStatement( - InvocationExpression( - MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, - IdentifierName("G"), IdentifierName("Pause")), - ArgumentList(SeparatedList(new[] { Argument(msExpr) }))))); - } - + caseStatements.Add(ReturnStatement()); break; } @@ -824,7 +741,7 @@ internal static InvocationExpressionSyntax BuildGetInvocation(string varName) /// Builds G.PluginCall("pluginName", "methodName", args...) expression. /// internal static InvocationExpressionSyntax BuildPluginCallExpression( - FormattedStatement stmt, Dictionary pubVarTypes) + CFGStatement stmt, Dictionary pubVarTypes) { var lastDot = (stmt.FullFunctionName ?? "").LastIndexOf('.'); var pluginName = lastDot >= 0 ? stmt.FullFunctionName![..lastDot] : stmt.FullFunctionName ?? ""; @@ -848,7 +765,7 @@ internal static InvocationExpressionSyntax BuildPluginCallExpression( /// Builds G.PluginCallWithTarget("pluginName", "methodName", "targetDevice", args...) expression. /// internal static InvocationExpressionSyntax BuildPluginCallWithTargetExpression( - FormattedStatement stmt, Dictionary pubVarTypes) + CFGStatement stmt, Dictionary pubVarTypes) { var pluginNameArg = stmt.Arguments.Count > 0 ? stmt.Arguments[0] : "\"\""; var methodNameArg = stmt.Arguments.Count > 1 ? stmt.Arguments[1] : "\"\""; @@ -895,4 +812,62 @@ internal static bool IsHelperFunction(string? name, List? helper if (name == null || helperFunctions == null) return false; return helperFunctions.Any(h => h.Name == name); } + + /// + /// Generates a simple G.Method(arg) call for single-argument globals methods like Print/Pause. + /// + private static void GenerateSimpleMethodCall(string methodName, CFGStatement stmt, + List caseStatements, Dictionary pubVarTypes) + { + if (stmt.Arguments.Count == 0) return; + var argExpr = ResolveArgumentExpression(stmt.Arguments[0], pubVarTypes); + caseStatements.Add(ExpressionStatement( + InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("G"), IdentifierName(methodName)), + ArgumentList(SeparatedList(new[] { Argument(argExpr) }))))); + } + + /// + /// Generates flow control statements (G.NextBlock = G.Branch/G.Loop/G.ToLoopCond(...)). + /// For Branch/Loop: 3 arguments (condition, trueBlock, falseBlock). + /// For ToLoopCond: 1 argument (returnToBlock). + /// + private static void GenerateFlowControl(string methodName, CFGStatement stmt, + List caseStatements, Dictionary pubVarTypes, + ref bool hasNextBlockAssignment, string? conditionPubVar = null, + string? trueBlockName = null, string? falseBlockName = null, + string? returnToBlock = null) + { + var resolved = new List(); + + if (methodName == "ToLoopCond") + { + resolved.Add(Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, + Literal(returnToBlock ?? "")))); + } + else + { + var condExpr = !string.IsNullOrEmpty(conditionPubVar) + ? ResolveArgumentExpression(conditionPubVar, pubVarTypes) + : ParseExpression(stmt.ConditionExpression ?? "false"); + resolved.Add(Argument(condExpr)); + resolved.Add(Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, + Literal(trueBlockName ?? "")))); + resolved.Add(Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, + Literal(falseBlockName ?? "")))); + } + + caseStatements.Add(ExpressionStatement( + AssignmentExpression(SyntaxKind.SimpleAssignmentExpression, + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("G"), IdentifierName("NextBlock")), + InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("G"), IdentifierName(methodName)), + ArgumentList(SeparatedList(resolved)))))); + + hasNextBlockAssignment = true; + caseStatements.Add(BreakStatement()); + } } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGBlock.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGBlock.cs index 46df6919..dd9bb666 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGBlock.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGBlock.cs @@ -3,7 +3,7 @@ namespace KitX.Core.Workflow.Blueprint.CFG; /// /// Type of a CFG block, indicating its structural role in the control flow. /// -internal enum CFGBlockType +public enum CFGBlockType { /// MainBlock — the entry point of the script. Entry, @@ -29,7 +29,7 @@ internal enum CFGBlockType /// statements with a single entry point and a single exit point (or /// control flow divergence at the end). /// -internal class CFGBlock +public class CFGBlock { /// /// Block name — stable across round-trips. For MainBlock, this is "#MainBlock". diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGBuilderFromBlueprint.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGBuilderFromBlueprint.cs index df1d8c58..b0951f6d 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGBuilderFromBlueprint.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGBuilderFromBlueprint.cs @@ -933,7 +933,7 @@ private static CFGStatement ConvertBlockStatementToCfgStatement(BlockStatement b { var cfgStmt = new CFGStatement { - Id = node.Id, // Use node ID as statement ID for node mapping + StatementId = node.Id, // Use node ID as statement ID for node mapping OriginalExpression = blockStmt.SourceCode, SourceLine = blockStmt.LineNumber, }; @@ -1015,7 +1015,7 @@ private static void ResolveControlFlowTargets( continue; // Find the Blueprint node corresponding to this statement - if (!nodeById.TryGetValue(stmt.Id, out var node)) + if (!nodeById.TryGetValue(stmt.StatementId, out var node)) continue; if (stmt.Kind == CFGStatementKind.Branch) @@ -1120,7 +1120,7 @@ private static void ResolveLoopTargets( { foreach (var stmt in block.Statements) { - if (stmt.Id == nodeId) + if (stmt.StatementId == nodeId) return stmt; } } @@ -1131,7 +1131,7 @@ private static void ResolveLoopTargets( { foreach (var block in cfg.Blocks) { - if (block.Statements.Any(s => s.Id == nodeId)) + if (block.Statements.Any(s => s.StatementId == nodeId)) return block.Name; } return null; @@ -1150,7 +1150,7 @@ private static void ResolveLoopTargets( // Fallback: find which CFG block contains a statement with this node ID foreach (var block in cfg.Blocks) { - if (block.Statements.Any(s => s.Id == targetNodeId)) + if (block.Statements.Any(s => s.StatementId == targetNodeId)) return block.Name; } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGBuilderFromScript.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGBuilderFromScript.cs index 36d3b6c6..cfb769f0 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGBuilderFromScript.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGBuilderFromScript.cs @@ -11,10 +11,10 @@ namespace KitX.Core.Workflow.Blueprint.CFG; /// /// Builds a from a AST. /// This is the canonical transformation for the BS→BP pipeline, replacing the -/// ad-hoc intermediate. +/// ad-hoc intermediate. /// /// Strategy: uses the existing for expression -/// expansion, then converts the to a +/// expansion, then converts the to a /// with typed edges. /// internal class CFGBuilderFromScript @@ -39,7 +39,7 @@ public ControlFlowGraph Build(BlockScript script, Pipeline.PipelineContext conte var formatter = new Pipeline.ScriptFormatter(_helperFunctions, _functionRegistry); var formatted = formatter.Format(script, context); - // Phase 2: Convert FormattedBlockScript to ControlFlowGraph + // Phase 2: Convert ControlFlowGraph to ControlFlowGraph var cfg = new ControlFlowGraph { HelperFunctions = _helperFunctions ?? [], @@ -114,11 +114,11 @@ public ControlFlowGraph Build(BlockScript script, Pipeline.PipelineContext conte // ─── Statement Conversion ────────────────────────────────────────── - private static CFGStatement ConvertStatement(Pipeline.FormattedStatement fmtStmt) + private static CFGStatement ConvertStatement(CFGStatement fmtStmt) { return new CFGStatement { - Id = fmtStmt.StatementId, + StatementId = fmtStmt.StatementId, BlockName = fmtStmt.BlockName, Kind = fmtStmt.Kind, OriginalExpression = fmtStmt.OriginalExpression, @@ -141,7 +141,7 @@ private static CFGStatement ConvertStatement(Pipeline.FormattedStatement fmtStmt // ─── Block Type Classification ────────────────────────────────────── - private static CFGBlockType ClassifyBlockType(Pipeline.FormattedBlock fmtBlock) + private static CFGBlockType ClassifyBlockType(CFGBlock fmtBlock) { if (fmtBlock.Name == MainBlock) return CFGBlockType.Entry; @@ -161,7 +161,7 @@ private static CFGBlockType ClassifyBlockType(Pipeline.FormattedBlock fmtBlock) // ─── Edge Building ───────────────────────────────────────────────── - private static void BuildEdgesFromBlock(CFGBlock cfgBlock, Pipeline.FormattedBlock fmtBlock) + private static void BuildEdgesFromBlock(CFGBlock cfgBlock, CFGBlock fmtBlock) { // Sequential fall-through edge (NextBlock) if (!string.IsNullOrEmpty(fmtBlock.NextBlockName) && !cfgBlock.EndsWithControlFlow) diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGConditionDuplicator.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGConditionDuplicator.cs index e37adef7..18057f92 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGConditionDuplicator.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGConditionDuplicator.cs @@ -75,7 +75,7 @@ private static void InsertConditionDuplicates(CFGBlock bodyBlock, List private static CFGStatement CloneStatement(CFGStatement source) => new() { - Id = source.Id, // Same ID — these are duplicates of the same logical statement + StatementId = source.StatementId, // Same ID — these are duplicates of the same logical statement BlockName = source.BlockName, Kind = source.Kind, OriginalExpression = source.OriginalExpression, diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGEdge.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGEdge.cs index 5f2169f1..7391bc60 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGEdge.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGEdge.cs @@ -4,7 +4,7 @@ namespace KitX.Core.Workflow.Blueprint.CFG; /// Type of a control flow edge between blocks. Making edge semantics /// explicit eliminates the need for heuristic-based control flow resolution. /// -internal enum CFGEdgeType +public enum CFGEdgeType { /// /// Sequential fall-through (NextBlock assignment). @@ -53,7 +53,7 @@ internal enum CFGEdgeType /// A typed edge in the Control Flow Graph, capturing the semantics /// of the transition between two blocks. /// -internal class CFGEdge +public class CFGEdge { /// /// The block this edge originates from. diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGStatement.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGStatement.cs index 2231b658..59b9585d 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGStatement.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGStatement.cs @@ -46,24 +46,20 @@ public enum CFGStatementKind /// TryGetDevice(deviceSearchPattern) — returns DeviceInfo or null TryGetDevice, - - /// PluginCall(pluginName, methodName[, args...]) — local plugin call - PluginCall, } -/// -/// A single statement within a CFG block. All expressions are flat — +/// within a CFG block. All expressions are flat — /// nested calls have been expanded into sequential PubVar assignments. /// Carries all information needed to produce a Blueprint node or a /// BlockScript statement. /// -internal class CFGStatement +public class CFGStatement { /// /// Unique identifier for this statement. Used to link CFG statements /// to Blueprint nodes during conversion. /// - public string Id { get; set; } = Guid.NewGuid().ToString(); + public string StatementId { get; set; } = Guid.NewGuid().ToString(); /// /// The block this statement belongs to. diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/ConstDeclaration.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/ConstDeclaration.cs index ee2e2ef1..bd335fb4 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/ConstDeclaration.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/ConstDeclaration.cs @@ -6,7 +6,7 @@ namespace KitX.Core.Workflow.Blueprint.CFG; /// value () to ensure correct round-tripping /// of string/char literals. /// -internal class ConstDeclaration +public class ConstDeclaration { /// Variable name (e.g. "bfCode", "memorySize"). public string Name { get; set; } = string.Empty; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/ControlFlowGraph.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/ControlFlowGraph.cs index 4e3bfb8a..8d7af0d6 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/ControlFlowGraph.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/ControlFlowGraph.cs @@ -8,8 +8,13 @@ namespace KitX.Core.Workflow.Blueprint.CFG; /// both BS→BP and BP→BS conversion. Both directions produce/consume /// a CFG, ensuring round-trip fidelity by structural equivalence. /// -internal class ControlFlowGraph +public class ControlFlowGraph { + /// + /// Main block name (always first in Blocks list). Computed from EntryBlock. + /// + public string MainBlockName { get; set; } = "MainBlock"; + /// /// All blocks in the CFG, ordered: MainBlock first, then named blocks /// in definition order. diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/DataEdgeBuilder.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/DataEdgeBuilder.cs index 9f64143c..a55871db 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/DataEdgeBuilder.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/DataEdgeBuilder.cs @@ -10,7 +10,7 @@ namespace KitX.Core.Workflow.Blueprint.Pipeline; /// -/// Phase 4+5: Creates data edges from FormattedBlockScript argument analysis. +/// Phase 4+5: Creates data edges from ControlFlowGraph argument analysis. /// Handles PubVar references, ConstBlock connections, DefaultValues, and data edge dedup. /// public class DataEdgeBuilder @@ -37,7 +37,7 @@ public void Build(PipelineContext context) // Statement processing // ────────────────────────────────────────────── - private void ProcessStatement(FormattedStatement stmt, PipelineContext context) + private void ProcessStatement(CFGStatement stmt, PipelineContext context) { switch (stmt.Kind) { @@ -72,7 +72,7 @@ private void ProcessStatement(FormattedStatement stmt, PipelineContext context) // Call/Assignment argument processing // ────────────────────────────────────────────── - private void ProcessCallArguments(FormattedStatement stmt, PipelineContext context) + private void ProcessCallArguments(CFGStatement stmt, PipelineContext context) { if (!context.NodeByStatementId.TryGetValue(stmt.StatementId, out var targetNode)) return; if (stmt.Arguments == null) return; @@ -91,7 +91,7 @@ private void ProcessCallArguments(FormattedStatement stmt, PipelineContext conte /// Processes a single argument expression and creates a data edge or sets DefaultValue. /// private void ProcessArgument(string arg, BlueprintNode targetNode, string targetPinName, - FormattedStatement parentStmt, int argIndex, PipelineContext context) + CFGStatement parentStmt, int argIndex, PipelineContext context) { var trimmed = arg.Trim(); @@ -155,7 +155,7 @@ private void ProcessArgument(string arg, BlueprintNode targetNode, string target // Special node argument processing // ────────────────────────────────────────────── - private void ProcessSingleValueInput(FormattedStatement stmt, List? args, + private void ProcessSingleValueInput(CFGStatement stmt, List? args, string pinName, PipelineContext context) { if (args == null || args.Count == 0) return; @@ -164,7 +164,7 @@ private void ProcessSingleValueInput(FormattedStatement stmt, List? args ProcessArgument(args[0], targetNode, pinName, stmt, 0, context); } - private void ProcessSetValue(FormattedStatement stmt, PipelineContext context) + private void ProcessSetValue(CFGStatement stmt, PipelineContext context) { if (!context.NodeByStatementId.TryGetValue(stmt.StatementId, out var targetNode)) return; if (stmt.Arguments == null || stmt.Arguments.Count == 0) return; @@ -176,7 +176,7 @@ private void ProcessSetValue(FormattedStatement stmt, PipelineContext context) } } - private void ProcessConditionInput(FormattedStatement stmt, PipelineContext context) + private void ProcessConditionInput(CFGStatement stmt, PipelineContext context) { if (string.IsNullOrEmpty(stmt.ConditionPubVar)) return; if (!context.NodeByStatementId.TryGetValue(stmt.StatementId, out var targetNode)) return; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/FormattedBlockScript.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/FormattedBlockScript.cs deleted file mode 100644 index 5b8026c3..00000000 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/FormattedBlockScript.cs +++ /dev/null @@ -1,108 +0,0 @@ -using System; -using System.Collections.Generic; -using KitX.Core.Workflow.Blueprint.CFG; - -using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Blocks; - -namespace KitX.Core.Workflow.Blueprint.Pipeline; - -/// -/// The formatted block script produced by Phase 2 (ScriptFormatter). -/// All nested calls have been expanded; each statement is a simple, flat form. -/// -public class FormattedBlockScript -{ - /// - /// Ordered list of blocks, starting with MainBlock. - /// - public List Blocks { get; set; } = new(); - - /// - /// Main block name (always first in Blocks list). - /// - public string MainBlockName { get; set; } = MainBlock; -} - -/// -/// A formatted block with flat statements. -/// -public class FormattedBlock -{ - public string Name { get; set; } = string.Empty; - public List Statements { get; set; } = new(); - public string? NextBlockName { get; set; } -} - -/// -/// A single flattened statement in the formatted script. -/// No nesting in expressions — all calls are either flat or assigned to PubVars. -/// -public class FormattedStatement -{ - public string StatementId { get; set; } = Guid.NewGuid().ToString(); - public string BlockName { get; set; } = string.Empty; - public string OriginalExpression { get; set; } = string.Empty; - public int SourceLine { get; set; } - - /// - /// What kind of statement this is. - /// - public CFGStatementKind Kind { get; set; } - - // --- For Assignment / Get --- - /// - /// The PubVar being assigned (e.g. "vaaa0001"), if this is an assignment. - /// - public string? PubVarTarget { get; set; } - - // --- For function calls --- - /// - /// Function name (e.g. "HelperFuncCompare", "Get", "Set", "Print"). - /// For plugin calls, this is the short name (e.g. "HelloKitX"). - /// - public string? FunctionName { get; set; } - - /// - /// Full dotted method path for plugin/external calls (e.g. "TestPlugin.WPF.Core.HelloKitX"). - /// Null for built-in and helper functions. - /// - public string? FullFunctionName { get; set; } - - /// - /// Raw argument strings after expansion (no nested calls). - /// Each argument is either a literal, a PubVar name, a ConstBlock variable name, or Get("varName"). - /// - public List Arguments { get; set; } = new(); - - // --- For flow control --- - public string? ConditionExpression { get; set; } - public string? ConditionPubVar { get; set; } - public string? TrueBlockName { get; set; } - public string? FalseBlockName { get; set; } - public string? ToLoopCondReturnTo { get; set; } - - // --- Metadata --- - /// - /// True if this statement was inserted by the ScriptFormatter as a Loop condition duplication - /// before a ToLoopCond statement. - /// - public bool IsLoopConditionDuplication { get; set; } - - // --- For Set --- - /// - /// The variable name being set (for Set statements). - /// - public string? SetVarName { get; set; } - - // --- For Get --- - /// - /// The variable name being read (for Get statements). - /// - public string? GetVarName { get; set; } - - // --- Expression fingerprint for reuse detection --- - /// - /// Computed fingerprint for reuse detection. Same expression → same fingerprint → reuse nodes. - /// - public string? Fingerprint { get; set; } -} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/NodeBuilder.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/NodeBuilder.cs index 99600246..52f9bff4 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/NodeBuilder.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/NodeBuilder.cs @@ -12,7 +12,7 @@ namespace KitX.Core.Workflow.Blueprint.Pipeline; /// -/// Phase 3: Creates all Blueprint nodes and exec flow edges from FormattedBlockScript. +/// Phase 3: Creates all Blueprint nodes and exec flow edges from ControlFlowGraph. /// Implements PubVar reuse detection during node creation (§6.5). /// public class NodeBuilder @@ -34,7 +34,7 @@ public NodeBuilder(INodeRegistry registry, List helpers, Builtin _functionRegistry = functionRegistry; } - public void Build(FormattedBlockScript script, PipelineContext context) + public void Build(ControlFlowGraph script, PipelineContext context) { // Create EntryNode var entry = (EntryNode)_registry.Create(BlueprintNodeType.Entry); @@ -59,7 +59,7 @@ public void Build(FormattedBlockScript script, PipelineContext context) // Block processing // ────────────────────────────────────────────── - private void ProcessBlock(FormattedBlock block, PipelineContext context) + private void ProcessBlock(CFGBlock block, PipelineContext context) { string? prevStmtId = null; BlueprintNode? prevNode = null; @@ -109,7 +109,7 @@ or CFGStatementKind.Break // Statement dispatch // ────────────────────────────────────────────── - private BlueprintNode? ProcessStatement(FormattedStatement stmt, string blockName, + private BlueprintNode? ProcessStatement(CFGStatement stmt, string blockName, PipelineContext context, ref BlueprintNode? prevNode, ref string? prevStmtId) { // Registry path: handle all registered block terminators (Branch/Loop/ToLoopCond/Break/Flip) @@ -139,7 +139,6 @@ or CFGStatementKind.Break case CFGStatementKind.Print: case CFGStatementKind.Pause: case CFGStatementKind.Set: - case CFGStatementKind.PluginCall: case CFGStatementKind.PluginCallWithTarget: { // Use registry to determine the legacy node type and configure it @@ -157,7 +156,6 @@ or CFGStatementKind.Break CFGStatementKind.Print => BlueprintNodeType.Print, CFGStatementKind.Pause => BlueprintNodeType.Pause, CFGStatementKind.Set => BlueprintNodeType.Set, - CFGStatementKind.PluginCall => BlueprintNodeType.Call, CFGStatementKind.PluginCallWithTarget => BlueprintNodeType.Call, _ => BlueprintNodeType.Call }; @@ -173,10 +171,10 @@ or CFGStatementKind.Break } /// - /// Checks if a FormattedStatement corresponds to a registry-based control flow terminator. + /// Checks if a CFGStatement corresponds to a registry-based control flow terminator. /// Used by ProcessBlock to set endsWithFlowCtrl flag. /// - private bool IsRegistryFlowControlTerminator(FormattedStatement stmt) + private bool IsRegistryFlowControlTerminator(CFGStatement stmt) { if (_functionRegistry == null || string.IsNullOrEmpty(stmt.FunctionName)) return false; var def = _functionRegistry.Get(stmt.FunctionName); @@ -187,7 +185,7 @@ private bool IsRegistryFlowControlTerminator(FormattedStatement stmt) // Call / Assignment node creation (with PubVar reuse) // ────────────────────────────────────────────── - private BlueprintNode? ProcessCallOrAssignment(FormattedStatement stmt, + private BlueprintNode? ProcessCallOrAssignment(CFGStatement stmt, PipelineContext context, ref BlueprintNode? prevNode, ref string? prevStmtId) { // --- PubVar reuse check (§6.5) --- @@ -383,7 +381,7 @@ private void ResolveCrossBlockEdges(PipelineContext context) // ────────────────────────────────────────────── /// Creates a node, registers it, and chains it into the exec flow. - private BlueprintNode ChainNewNode(BlueprintNode node, FormattedStatement stmt, + private BlueprintNode ChainNewNode(BlueprintNode node, CFGStatement stmt, PipelineContext context, ref BlueprintNode? prevNode, ref string? prevStmtId) { context.AllNodes.Add(node); diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineContext.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineContext.cs index a3d1e12a..57dc828f 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineContext.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineContext.cs @@ -1,4 +1,5 @@ using System; +using KitX.Core.Workflow.Blueprint.CFG; using System.Collections.Generic; using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Pins; @@ -22,7 +23,7 @@ public class PipelineContext public List PubVarNames { get; set; } = new(); // --- Phase 2 output --- - public FormattedBlockScript FormattedScript { get; set; } = new(); + public ControlFlowGraph FormattedScript { get; set; } = new(); // --- Phase 3 output --- public List AllNodes { get; set; } = new(); @@ -121,7 +122,7 @@ public class ConditionInfo /// The formatted statements that evaluate the condition (may be multiple if nested). /// The last one assigns to ConditionPubVar. /// - public List ExpansionStatements { get; set; } = new(); + public List ExpansionStatements { get; set; } = new(); } /// diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ScriptFormatter.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ScriptFormatter.cs index 6117ea34..3cf7dd42 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ScriptFormatter.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ScriptFormatter.cs @@ -13,7 +13,7 @@ namespace KitX.Core.Workflow.Blueprint.Pipeline; /// -/// Phase 2: Takes a parsed BlockScript AST and produces a FormattedBlockScript +/// Phase 2: Takes a parsed BlockScript AST and produces a ControlFlowGraph /// where all nested function calls have been expanded into PubVar assignments. /// Also duplicates Loop condition evaluations before ToLoopCond statements. /// @@ -28,9 +28,9 @@ public ScriptFormatter(List helperFunctions, BuiltinFunctionRegi _functionRegistry = functionRegistry; } - public FormattedBlockScript Format(BlockScript script, PipelineContext context) + public ControlFlowGraph Format(BlockScript script, PipelineContext context) { - var result = new FormattedBlockScript(); + var result = new ControlFlowGraph(); // Initialize counter: find max existing PubVar counter to avoid conflicts context.NextPubVarCounter = 1; @@ -68,9 +68,9 @@ public FormattedBlockScript Format(BlockScript script, PipelineContext context) // Block-level formatting // ────────────────────────────────────────────── - private FormattedBlock FormatBlock(BlockDefinition blockDef, PipelineContext context) + private CFGBlock FormatBlock(BlockDefinition blockDef, PipelineContext context) { - var result = new FormattedBlock { Name = blockDef.Name, NextBlockName = blockDef.NextBlockName }; + var result = new CFGBlock { Name = blockDef.Name, NextBlockName = blockDef.NextBlockName }; foreach (var stmt in blockDef.Statements) { var formatted = FormatStatement(stmt, blockDef.Name, context); @@ -80,7 +80,7 @@ private FormattedBlock FormatBlock(BlockDefinition blockDef, PipelineContext con } /// Returns a list of FormattedStatements (may be multiple when expansion occurs). - private List FormatStatement(BlockStatement stmt, string blockName, PipelineContext context) + private List FormatStatement(BlockStatement stmt, string blockName, PipelineContext context) { switch (stmt) { @@ -97,9 +97,9 @@ private List FormatStatement(BlockStatement stmt, string blo // Flow control formatting // ────────────────────────────────────────────── - private List FormatFlowControl(FlowControlStatement flowCtrl, string blockName, PipelineContext context) + private List FormatFlowControl(FlowControlStatement flowCtrl, string blockName, PipelineContext context) { - var result = new List(); + var result = new List(); switch (flowCtrl.ControlType) { @@ -107,7 +107,7 @@ private List FormatFlowControl(FlowControlStatement flowCtrl { var (condStmts, condPubVar) = ExpandCondition(flowCtrl.ConditionExpression, blockName, context); result.AddRange(condStmts); - result.Add(new FormattedStatement + result.Add(new CFGStatement { BlockName = blockName, Kind = CFGStatementKind.Branch, @@ -127,7 +127,7 @@ private List FormatFlowControl(FlowControlStatement flowCtrl var (condStmts, condPubVar) = ExpandCondition(flowCtrl.ConditionExpression, blockName, context); result.AddRange(condStmts); - var loopStmt = new FormattedStatement + var loopStmt = new CFGStatement { BlockName = blockName, Kind = CFGStatementKind.Loop, @@ -155,7 +155,7 @@ private List FormatFlowControl(FlowControlStatement flowCtrl break; case FlowControlType.ToLoopCond: - result.Add(new FormattedStatement + result.Add(new CFGStatement { BlockName = blockName, Kind = CFGStatementKind.ToLoopCond, @@ -167,7 +167,7 @@ private List FormatFlowControl(FlowControlStatement flowCtrl break; case FlowControlType.Break: - result.Add(new FormattedStatement + result.Add(new CFGStatement { BlockName = blockName, Kind = CFGStatementKind.Break, @@ -184,9 +184,9 @@ private List FormatFlowControl(FlowControlStatement flowCtrl // Expression statement formatting // ────────────────────────────────────────────── - private List FormatExpressionStatement(ExpressionStatement exprStmt, string blockName, PipelineContext context) + private List FormatExpressionStatement(ExpressionStatement exprStmt, string blockName, PipelineContext context) { - var result = new List(); + var result = new List(); var expression = exprStmt.Expression; // Try to parse the expression @@ -223,11 +223,11 @@ private List FormatExpressionStatement(ExpressionStatement e /// /// Formats a function invocation, expanding nested calls in arguments. /// - private List FormatInvocation( + private List FormatInvocation( InvocationExpressionSyntax invoke, string funcName, string blockName, PipelineContext context, string? assignedVar, string? fullFuncName = null) { - var result = new List(); + var result = new List(); // Expand nested calls in arguments first var (expansionStmts, currentArgExprs) = ExpandArguments(invoke, blockName, context); @@ -282,7 +282,7 @@ private List FormatInvocation( ? ExprUtils.ComputeFingerprint(funcName, currentArgExprs) : null; - result.Add(new FormattedStatement + result.Add(new CFGStatement { BlockName = blockName, Kind = kind, @@ -308,10 +308,10 @@ private List FormatInvocation( /// Expands nested function calls in arguments. /// Returns (expansionStatements, currentArgStrings). /// - private (List stmts, List argExprs) ExpandArguments( + private (List stmts, List argExprs) ExpandArguments( InvocationExpressionSyntax invoke, string blockName, PipelineContext context) { - var stmts = new List(); + var stmts = new List(); var argExprs = new List(); foreach (var arg in invoke.ArgumentList.Arguments) @@ -328,7 +328,7 @@ private List FormatInvocation( /// Recursively expands nested calls within a single expression. /// Returns (expansionStatements, finalExpressionString). /// - private (List stmts, string finalExpr) ExpandExpression( + private (List stmts, string finalExpr) ExpandExpression( ExpressionSyntax expr, string blockName, PipelineContext context) { // Literal → return as-is @@ -369,7 +369,7 @@ private List FormatInvocation( // This is a helper/regular function call that needs extraction // First, recursively expand ITS arguments - var allStmts = new List(); + var allStmts = new List(); var currentArgs = new List(); foreach (var arg in invoke.ArgumentList.Arguments) { @@ -385,7 +385,7 @@ private List FormatInvocation( var fingerprint = ExprUtils.ComputeFingerprint(funcName, currentArgs); - allStmts.Add(new FormattedStatement + allStmts.Add(new CFGStatement { BlockName = blockName, Kind = CFGStatementKind.Assignment, @@ -412,10 +412,10 @@ private List FormatInvocation( // Condition expansion (for Branch/Loop) // ────────────────────────────────────────────── - private (List stmts, string? pubVar) ExpandCondition( + private (List stmts, string? pubVar) ExpandCondition( string conditionExpression, string blockName, PipelineContext context) { - var result = new List(); + var result = new List(); if (string.IsNullOrWhiteSpace(conditionExpression)) return (result, null); From 47a1b60ab0a260e5a7327c617e1f7528c1cd67bc Mon Sep 17 00:00:00 2001 From: StarInk Date: Tue, 5 May 2026 22:14:44 +0200 Subject: [PATCH 067/110] =?UTF-8?q?=F0=9F=94=A7=20Refactor(Workflow):=20?= =?UTF-8?q?=E7=A7=BB=E9=99=A4=E8=84=9A=E6=9C=AC=E6=89=A7=E8=A1=8C=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E5=92=8C=E7=9B=B8=E5=85=B3=E4=BE=9D=E8=B5=96=EF=BC=8C?= =?UTF-8?q?=E7=AE=80=E5=8C=96=E5=B7=A5=E4=BD=9C=E6=B5=81=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../KitX Core/KitX.Core.DI.Tests/Program.cs | 1 - .../DI/CoreServiceCollectionExtensions.cs | 5 - .../KitX Core/KitX.Core/KitX.Core.csproj | 2 +- .../KitX.Core/Workflow/KcsFileService.cs | 41 --- .../KitX.Core/Workflow/RealPluginManager.cs | 21 +- .../Workflow/ScriptExecutionService.cs | 319 ------------------ .../Workflow/WorkflowManagementService.cs | 6 +- .../Workflow/WorkflowPluginService.cs | 5 +- .../Workflow/WorkflowScriptService.cs | 35 +- KitX Clients/KitX Dashboard | 2 +- KitX SDK | 2 +- KitX Standard | 2 +- 12 files changed, 9 insertions(+), 432 deletions(-) delete mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/ScriptExecutionService.cs diff --git a/KitX Clients/KitX Core/KitX.Core.DI.Tests/Program.cs b/KitX Clients/KitX Core/KitX.Core.DI.Tests/Program.cs index 8e208e52..70e7283c 100644 --- a/KitX Clients/KitX Core/KitX.Core.DI.Tests/Program.cs +++ b/KitX Clients/KitX Core/KitX.Core.DI.Tests/Program.cs @@ -58,7 +58,6 @@ private static void TestServiceResolution() TestService(serviceProvider, "IEncryptionService"); TestService(serviceProvider, "IPluginService"); TestService(serviceProvider, "IWorkflowManagementService"); - TestService(serviceProvider, "IScriptExecutionService"); TestService(serviceProvider, "IWorkflowPluginService"); TestService(serviceProvider, "IBlockScriptService"); TestService(serviceProvider, "IActivityService"); diff --git a/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs b/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs index b7ed22ba..3756c514 100644 --- a/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs +++ b/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs @@ -72,7 +72,6 @@ public static IServiceCollection AddCoreServices(this IServiceCollection service return service; }); services.AddSingleton(sp => WorkflowScriptService.PluginServiceInstance); - services.AddSingleton(sp => WorkflowScriptService.ScriptExecutionServiceInstance); services.AddSingleton(sp => WorkflowScriptService.ManagementServiceInstance); // Activity Services @@ -138,10 +137,6 @@ public static IServiceCollection AddCoreServices(this IServiceCollection service Log.Information("Registering IKcsFileService..."); services.AddSingleton(); - // Main Program Analyzer - Log.Information("Registering IMainProgramAnalyzer..."); - services.AddSingleton(); - // Block Script Services Log.Information("Registering IBlockScriptParser..."); services.AddSingleton(provider => diff --git a/KitX Clients/KitX Core/KitX.Core/KitX.Core.csproj b/KitX Clients/KitX Core/KitX.Core/KitX.Core.csproj index 47c14df6..4abf2fc4 100644 --- a/KitX Clients/KitX Core/KitX.Core/KitX.Core.csproj +++ b/KitX Clients/KitX Core/KitX.Core/KitX.Core.csproj @@ -26,7 +26,7 @@ - + diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/KcsFileService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/KcsFileService.cs index 94af15a9..67710e7b 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/KcsFileService.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/KcsFileService.cs @@ -58,47 +58,6 @@ public async System.Threading.Tasks.Task SaveKcsFileAsync(string filePath, KcsFi } } -/// -/// 主程序代码分析器实现 - 使用 CSharpSyntaxWalker 检查禁止的语法 -/// -public class MainProgramAnalyzer : IMainProgramAnalyzer -{ - /// - /// 分析代码 - /// - public MainProgramAnalysisResult Analyze(string code) - { - var result = new MainProgramAnalysisResult { IsValid = true }; - - if (string.IsNullOrWhiteSpace(code)) - { - return result; - } - - try - { - // 解析代码为语法树 - var syntaxTree = CSharpSyntaxTree.ParseText(code); - var root = syntaxTree.GetRoot(); - - // 使用 StrictScriptValidator 进行语法检查 - var validator = new StrictScriptValidator(); - validator.Visit(root); - - result.IsValid = validator.IsValid; - result.ForbiddenReason = validator.ForbiddenReason; - } - catch (Exception ex) - { - result.IsValid = false; - result.ForbiddenReason = $"Code parsing error: {ex.Message}"; - Log.Warning(ex, "[MainProgramAnalyzer] Error analyzing code"); - } - - return result; - } -} - /// /// 严格的脚本验证器 - 使用 CSharpSyntaxWalker 检查允许的语法 /// diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/RealPluginManager.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/RealPluginManager.cs index ae594e9f..19c26af0 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/RealPluginManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/RealPluginManager.cs @@ -21,10 +21,6 @@ using Serilog; using Microsoft.Extensions.DependencyInjection; -using KcsPluginCallInfo = Kscript.CSharp.Parser.Models.PluginCallInfo; -using KcsIPluginManager = Kscript.CSharp.Parser.Core.IPluginManager; -using PluginMessageReceivedEventArgs = KitX.Core.Contract.Plugin.Events.PluginMessageReceivedEventArgs; - namespace KitX.Core.Workflow; /// @@ -32,7 +28,7 @@ namespace KitX.Core.Workflow; /// Implements both Contract.IPluginManager (primary, for BlockScripting) and /// KCS IPluginManager (legacy, for KCS pipeline compatibility). /// -public class RealPluginManager : IPluginManager, KcsIPluginManager +public class RealPluginManager : IPluginManager { private readonly IPluginServer _pluginServer; private readonly IDeviceServer _deviceServer; @@ -231,21 +227,6 @@ public T Call(PluginCallInfo callInfo) var result = CallAsync(callInfo).GetAwaiter().GetResult(); return ParseResult(result); } - - // ── KCS IPluginManager explicit implementation (legacy compatibility) ── - - void KcsIPluginManager.Call(KcsPluginCallInfo callInfo) - => Call(ConvertFromKcs(callInfo)); - - T KcsIPluginManager.Call(KcsPluginCallInfo callInfo) - => Call(ConvertFromKcs(callInfo)); - - bool KcsIPluginManager.IsPluginExists(string pluginName) => IsPluginExists(pluginName); - bool KcsIPluginManager.IsMethodExists(string pluginName, string methodName) => IsMethodExists(pluginName, methodName); - - private static PluginCallInfo ConvertFromKcs(KcsPluginCallInfo kcs) - => new(kcs.PluginName, kcs.MethodName, kcs.Parameters, kcs.ParameterTypes, kcs.ParameterNames); - /// /// 自动调用插件方法:根据函数声明的返回类型自动选择调用策略。 /// - void 返回类型 → fire-and-forget (Call),不等待响应 diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/ScriptExecutionService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/ScriptExecutionService.cs deleted file mode 100644 index be610a14..00000000 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/ScriptExecutionService.cs +++ /dev/null @@ -1,319 +0,0 @@ -using System; -using System.Diagnostics; -using System.Linq; -using System.Reflection; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using Csharpell.Core; -using KitX.Core.Contract.Workflow; -using KitX.Shared.CSharp.Plugin; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Scripting; -using Serilog; - -namespace KitX.Core.Workflow; - -/// -/// C# script execution service. -/// Implements IScriptExecutionService. -/// -internal class ScriptExecutionService : IScriptExecutionService -{ - private readonly WorkflowRuntimeState _state; - private readonly IWorkflowPluginService _pluginService; - - /// - /// Initializes a new instance of ScriptExecutionService. - /// - /// Shared runtime state. - /// Plugin service for constants and helper functions. - internal ScriptExecutionService(WorkflowRuntimeState state, IWorkflowPluginService pluginService) - { - _state = state; - _pluginService = pluginService; - } - - /// - /// Gets the script engine, creating it if necessary. - /// - private CSharpScriptEngine Engine => _state.Engine ??= new CSharpScriptEngine(); - - /// - public async Task ExecuteScriptAsync(string script, System.Collections.Generic.Dictionary? parameters = null) - { - const string location = $"{nameof(ScriptExecutionService)}.{nameof(ExecuteScriptAsync)}"; - - try - { - if (string.IsNullOrWhiteSpace(script)) - { - Log.Warning("Script is empty"); - return await System.Threading.Tasks.Task.FromResult(null); - } - - var result = await ExecuteCodesAsync(script, null, true, CancellationToken.None); - return await System.Threading.Tasks.Task.FromResult(result); - } - catch (Exception ex) - { - Log.Error(ex, $"In {location}: Error executing script: {ex.Message}"); - return await System.Threading.Tasks.Task.FromResult(null); - } - } - - /// - public async Task ExecuteCodesAsync( - string code, - System.Collections.Generic.List? requiredPlugins = null, - bool includeTimestamp = true, - CancellationToken cancellationToken = default) - { - const string location = $"{nameof(ScriptExecutionService)}.{nameof(ExecuteCodesAsync)}"; - - var sw = new Stopwatch(); - var begin = DateTime.Now; - sw.Start(); - - try - { - if (requiredPlugins != null && requiredPlugins.Any()) - { - if (!_state.IsParserInitialized) - { - _pluginService.InitializePluginManager(); - } - - var pluginsReady = await EnsurePluginsReadyAsync(requiredPlugins, cancellationToken); - - if (!pluginsReady) - { - Log.Warning("[ScriptExecutionService] Some plugins failed to start, but continuing script execution"); - } - - Assembly? pluginApiAssembly = null; - try - { - pluginApiAssembly = Kscript.CSharp.Parser.Parser.Generate(requiredPlugins, "KitXWorkflowPlugins", useCache: false); - Log.Information($"[ScriptExecutionService] Successfully generated plugin API with {requiredPlugins.Count} plugins"); - } - catch (Exception ex) - { - var error = $"Failed to generate plugin API: {ex.Message}"; - Log.Error(ex, error); - } - - var result = await ExecuteScriptWithPluginsAsync( - code, - pluginApiAssembly, - includeTimestamp, - cancellationToken - ); - - sw.Stop(); - return FormatExecutionResult(result, begin, sw.ElapsedMilliseconds, includeTimestamp); - } - else - { - return await ExecuteCodesWithoutPluginsAsync(code, includeTimestamp, cancellationToken); - } - } - catch (Exception ex) - { - sw.Stop(); - Log.Error(ex, $"In {location}: Error executing code: {ex.Message}"); - return FormatExecutionError(ex, begin, sw.ElapsedMilliseconds, includeTimestamp); - } - } - - /// - public async Task ExecuteKcsCodesAsync( - string mainCode, - System.Collections.Generic.List helperFunctions, - System.Collections.Generic.List constants, - System.Collections.Generic.List? requiredPlugins = null, - bool includeTimestamp = true, - CancellationToken cancellationToken = default) - { - // 1. Analyze main program - var analyzer = new MainProgramAnalyzer(); - var analysisResult = analyzer.Analyze(mainCode); - - if (!analysisResult.IsValid) - { - return includeTimestamp - ? $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [E] Code analysis failed: {analysisResult.ForbiddenReason}" - : $"Code analysis failed: {analysisResult.ForbiddenReason}"; - } - - // 2. Apply constants - var codeWithConstants = _pluginService.ApplyConstantsToCode(mainCode, constants); - - // 3. Merge helper functions - var fullCode = _pluginService.MergeHelperFunctions(codeWithConstants, helperFunctions); - - // 4. Execute - return await ExecuteCodesAsync(fullCode, requiredPlugins, includeTimestamp, cancellationToken); - } - - /// - /// Executes script with plugin dependencies. - /// - private async Task ExecuteScriptWithPluginsAsync( - string code, - Assembly? pluginApiAssembly, - bool includeTimestamp, - CancellationToken cancellationToken) - { - try - { - WorkflowOutput.GetAndClear(); - - var result = await Engine.ExecuteAsync( - code, - options => - { - options = options - .WithReferences(Assembly.GetExecutingAssembly()) - .WithImports( - "KitX", - "KitX.Core", - "KitX.Core.Workflow", - "KitX.Shared.CSharp.Plugin", - "System", - "System.Collections.Generic", - "System.Threading.Tasks" - ) - .WithLanguageVersion(LanguageVersion.Preview); - - if (pluginApiAssembly != null) - { - options = options.WithReferences(pluginApiAssembly); - } - - return options; - }, - addDefaultImports: true, - runInReplMode: false, - cancellationToken: cancellationToken - ); - - var scriptOutput = WorkflowOutput.GetAndClear(); - var returnValue = result?.ToString(); - - return CombineOutput(scriptOutput, returnValue); - } - catch (Exception ex) - { - Log.Error(ex, "[ScriptExecutionService] Error executing script with plugins"); - return $"Script execution error: {ex.Message}"; - } - } - - /// - /// Ensures required plugins are loaded and running. - /// - private Task EnsurePluginsReadyAsync( - System.Collections.Generic.List requiredPlugins, - CancellationToken cancellationToken = default) - { - Log.Information($"[ScriptExecutionService] Checking {requiredPlugins.Count} required plugins"); - - foreach (var plugin in requiredPlugins) - { - Log.Information($"[ScriptExecutionService] Required plugin: {plugin.Name}"); - } - - return System.Threading.Tasks.Task.FromResult(true); - } - - /// - /// Executes code without plugin dependencies. - /// - private async Task ExecuteCodesWithoutPluginsAsync( - string code, - bool includeTimestamp, - CancellationToken cancellationToken) - { - var sw = new Stopwatch(); - var begin = DateTime.Now; - sw.Start(); - - try - { - WorkflowOutput.GetAndClear(); - - var result = await Engine.ExecuteAsync( - code, - options => options - .WithReferences(Assembly.GetExecutingAssembly()) - .WithImports( - "System", - "System.Collections.Generic", - "System.Threading.Tasks", - "KitX.Core.Workflow" - ) - .WithLanguageVersion(LanguageVersion.Preview), - addDefaultImports: true, - runInReplMode: false, - cancellationToken: cancellationToken - ); - - sw.Stop(); - - var scriptOutput = WorkflowOutput.GetAndClear(); - var returnValue = result?.ToString(); - - var combinedOutput = CombineOutput(scriptOutput, returnValue); - - return FormatExecutionResult(combinedOutput, begin, sw.ElapsedMilliseconds, includeTimestamp); - } - catch (Exception ex) - { - sw.Stop(); - return FormatExecutionError(ex, begin, sw.ElapsedMilliseconds, includeTimestamp); - } - } - - /// - /// Formats successful execution output with optional timestamp header. - /// - private static string? FormatExecutionResult(string? output, DateTime begin, long elapsedMs, bool includeTimestamp) - { - if (!includeTimestamp) - return output; - - return new StringBuilder() - .AppendLine($"[{begin:yyyy-MM-dd HH:mm:ss}] [I] Workflow script posted.") - .AppendLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [I] Script ended, took {elapsedMs} ms.") - .AppendLine(output) - .ToString(); - } - - /// - /// Formats execution error with optional timestamp header. - /// - private static string? FormatExecutionError(Exception ex, DateTime begin, long elapsedMs, bool includeTimestamp) - { - if (!includeTimestamp) - return ex.StackTrace; - - return new StringBuilder() - .AppendLine($"[{begin:yyyy-MM-dd HH:mm:ss}] [I] Workflow script posted.") - .AppendLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [E] Exception caught after {elapsedMs} ms, Message: {ex.Message}") - .AppendLine(ex.StackTrace) - .ToString(); - } - - /// - /// Combines WorkflowOutput and script return value into a single output string. - /// - private static string? CombineOutput(string? scriptOutput, string? returnValue) - { - return !string.IsNullOrEmpty(scriptOutput) - ? (string.IsNullOrEmpty(returnValue) - ? scriptOutput - : $"{scriptOutput}\n{returnValue}") - : returnValue; - } -} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowManagementService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowManagementService.cs index 043f94ee..85b63395 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowManagementService.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowManagementService.cs @@ -83,14 +83,10 @@ public async Task RunWorkflowAsync(string workflowId) string sourceCode; List? helpers = data.HelperFunctions; - if (data.UseBlockMode && !string.IsNullOrWhiteSpace(data.BlockScriptSource)) + if (!string.IsNullOrWhiteSpace(data.BlockScriptSource)) { sourceCode = data.BlockScriptSource; } - else if (!string.IsNullOrWhiteSpace(data.MainProgram)) - { - sourceCode = data.MainProgram; - } else { Log.Warning("[{Location}] Workflow '{Name}' (ID: {Id}) has no executable source code", diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowPluginService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowPluginService.cs index b29ecc6a..8c2bdce1 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowPluginService.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowPluginService.cs @@ -40,15 +40,12 @@ public void InitializePluginManager() : new KitX.Core.Device.PluginsServer(new KitX.Core.Event.EventService()); var realPluginManager = new RealPluginManager(pluginServer); - Kscript.CSharp.Parser.Parser.SetPluginManager(realPluginManager); - _state.IsParserInitialized = true; Log.Information("[WorkflowPluginService] Real plugin manager initialized"); } catch (Exception ex) { - Log.Error($"[WorkflowPluginService] Failed to initialize real plugin manager: {ex.Message}, falling back to mock"); - Kscript.CSharp.Parser.Parser.SetPluginManager(new Kscript.CSharp.Parser.Core.MockPluginManager()); + Log.Error($"[WorkflowPluginService] Failed to initialize real plugin manager: {ex.Message}"); _state.IsParserInitialized = true; } } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs index 16871e9c..83a5aaf5 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs @@ -14,7 +14,7 @@ namespace KitX.Core.Workflow; /// Facade service that delegates to specialized workflow services. /// Exposes all four workflow interfaces for backward compatibility. /// -public class WorkflowScriptService : IWorkflowManagementService, IScriptExecutionService, +public class WorkflowScriptService : IWorkflowManagementService, IWorkflowPluginService, IBlockScriptService { /// @@ -36,7 +36,6 @@ public class WorkflowScriptService : IWorkflowManagementService, IScriptExecutio private static IBlockScriptService? _blockScriptService; private static IWorkflowManagementService? _managementService; private static readonly IWorkflowPluginService PluginService; - private static readonly IScriptExecutionService ScriptExecutionService; /// /// Static constructor initializes the service graph in dependency order. @@ -49,10 +48,7 @@ static WorkflowScriptService() // 1. PluginService (depends only on SharedState) PluginService = new WorkflowPluginService(SharedState); - // 2. ScriptExecutionService (depends on PluginService) - ScriptExecutionService = new ScriptExecutionService(SharedState, PluginService); - - // 3. ManagementService - will be initialized lazily when first accessed + // 2. ManagementService - will be initialized lazily when first accessed // BlockScriptService is also lazy-initialized, so ManagementService should not // be created here to avoid using a partially initialized BlockScriptService } @@ -73,7 +69,6 @@ private static IBlockScriptService BlockScriptService rpm = ServiceHost.GetRequiredService(); if (rpm != null) { - Kscript.CSharp.Parser.Parser.SetPluginManager(rpm); SharedState.IsParserInitialized = true; Log.Information("[WorkflowScriptService] Real plugin manager obtained. HashCode: {HashCode}", rpm.GetHashCode()); } @@ -105,11 +100,6 @@ private static IBlockScriptService BlockScriptService /// internal static IWorkflowPluginService PluginServiceInstance => PluginService; - /// - /// Exposes IScriptExecutionService for DI registration. - /// - internal static IScriptExecutionService ScriptExecutionServiceInstance => ScriptExecutionService; - /// /// Gets the ManagementService, creating it lazily once BlockScriptService is available. /// @@ -141,27 +131,6 @@ private WorkflowScriptService() public Task CompileAndPersistWorkflowAsync(string workflowId) => ManagementService.CompileAndPersistWorkflowAsync(workflowId); - // --- IScriptExecutionService --- - - public Task ExecuteScriptAsync(string script, Dictionary? parameters = null) => - ScriptExecutionService.ExecuteScriptAsync(script, parameters); - - public Task ExecuteCodesAsync( - string code, - List? requiredPlugins = null, - bool includeTimestamp = true, - CancellationToken cancellationToken = default) => - ScriptExecutionService.ExecuteCodesAsync(code, requiredPlugins, includeTimestamp, cancellationToken); - - public Task ExecuteKcsCodesAsync( - string mainCode, - List helperFunctions, - List constants, - List? requiredPlugins = null, - bool includeTimestamp = true, - CancellationToken cancellationToken = default) => - ScriptExecutionService.ExecuteKcsCodesAsync(mainCode, helperFunctions, constants, requiredPlugins, includeTimestamp, cancellationToken); - // --- IWorkflowPluginService --- public void InitializePluginManager() => PluginService.InitializePluginManager(); diff --git a/KitX Clients/KitX Dashboard b/KitX Clients/KitX Dashboard index e5d7e959..52ab8972 160000 --- a/KitX Clients/KitX Dashboard +++ b/KitX Clients/KitX Dashboard @@ -1 +1 @@ -Subproject commit e5d7e959e5c303ceafff3b7c9a995bca39204b31 +Subproject commit 52ab89728c3e664700583134bac392fc0b1e16b3 diff --git a/KitX SDK b/KitX SDK index 1e5c4c92..17938752 160000 --- a/KitX SDK +++ b/KitX SDK @@ -1 +1 @@ -Subproject commit 1e5c4c92eef0ab40c68e58b2ea6ab4d359ecfee8 +Subproject commit 17938752f7d42d1d1dde718241a88eef4cc07d1b diff --git a/KitX Standard b/KitX Standard index f8509d2b..ddd94f7e 160000 --- a/KitX Standard +++ b/KitX Standard @@ -1 +1 @@ -Subproject commit f8509d2b2531958dfd2307a04ec32e38b8f0f6c6 +Subproject commit ddd94f7efd13cc6b60bf2109047c4b8134dfa2e6 From 2b455621f0fb0757111967dc4667207279cd224c Mon Sep 17 00:00:00 2001 From: StarInk Date: Sun, 17 May 2026 06:10:46 +0200 Subject: [PATCH 068/110] =?UTF-8?q?=F0=9F=94=A7=20Fix(Configuration):=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=85=8D=E7=BD=AE=E5=8A=A0=E8=BD=BDbug?= =?UTF-8?q?=E5=B9=B6=E6=B7=BB=E5=8A=A0=E9=A2=9D=E5=A4=96=E7=9A=84=E5=88=9D?= =?UTF-8?q?=E5=A7=8B=E5=8C=96=E5=89=8D=E7=9A=84=E5=B8=B8=E8=A7=84=E8=AF=8A?= =?UTF-8?q?=E6=96=AD=E6=97=A5=E5=BF=97=EF=BC=88=E5=BE=85=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E7=A8=B3=E5=AE=9A=E5=90=8E=E5=88=A0=E9=99=A4=EF=BC=89=EF=BC=8C?= =?UTF-8?q?=E7=AE=80=E5=8C=96=E8=B7=AF=E5=BE=84=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../KitX.Core/Configuration/ConfigLoader.cs | 45 ++++++++++++++++--- .../KitX.Core/Configuration/ConfigManager.cs | 36 ++++++++++++--- .../KitX.Core/Configuration/ConfigSaver.cs | 5 ++- .../ConfigSerializationOptions.cs | 1 - 4 files changed, 72 insertions(+), 15 deletions(-) diff --git a/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigLoader.cs b/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigLoader.cs index 65fba9d4..73ed4a26 100644 --- a/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigLoader.cs +++ b/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigLoader.cs @@ -16,23 +16,56 @@ public class ConfigLoader : IConfigLoader /// public T Load(string location, string fileName) where T : class, new() { - var path = Path.Combine(location, fileName); + var rawPath = Path.Combine(location, fileName); + var fullPath = Path.GetFullPath(rawPath); - if (!File.Exists(path)) + // Diagnostic trail — always written, bypasses Serilog + var diagPath = Path.Combine(Path.GetDirectoryName(fullPath) ?? ".", "ConfigLoadTrail.log"); + var fInfo = new FileInfo(fullPath); + File.AppendAllText(diagPath, $"[{DateTime.Now:O}] Load<{typeof(T).Name}> path={fullPath}, exists={(fInfo.Exists ? "True" : "False")}, size={(fInfo.Exists ? fInfo.Length.ToString() : "n/a")}, lastWrite={(fInfo.Exists ? fInfo.LastWriteTime.ToString("O") : "n/a")}\n"); + + if (!File.Exists(fullPath)) { - Log.Warning("Config file {FileName} not found, creating default", fileName); + File.AppendAllText(diagPath, $"[{DateTime.Now:O}] Load<{typeof(T).Name}> FILE NOT FOUND, returning default\n"); return new T(); } try { - var json = File.ReadAllText(path); + var json = File.ReadAllText(fullPath); var config = JsonSerializer.Deserialize(json, ConfigSerializationOptions.Options); - return config ?? new T(); + if (config == null) + { + File.AppendAllText(diagPath, $"[{DateTime.Now:O}] Load<{typeof(T).Name}> Deserialize returned NULL, returning default\n"); + return new T(); + } + if (typeof(T) == typeof(AppConfig)) + { + var ac = (AppConfig)(object)config; + + // Snapshot: parse JSON directly to see what the file really says + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + string jLogLevel = "?", jHomePane = "?", jHomeSelView = "?"; + if (root.TryGetProperty("Log", out var jLog) && jLog.TryGetProperty("LogLevel", out var jLevel)) + jLogLevel = jLevel.GetInt32().ToString(); + if (root.TryGetProperty("Pages", out var jPages) && jPages.TryGetProperty("Home", out var jHome)) + { + if (jHome.TryGetProperty("IsNavigationViewPaneOpened", out var jOpen)) + jHomePane = jOpen.GetBoolean() ? "open" : "closed"; + if (jHome.TryGetProperty("SelectedViewName", out var jSvn)) + jHomeSelView = jSvn.GetString() ?? "null"; + } + + File.AppendAllText(diagPath, $"[{DateTime.Now:O}] Load JSON: LogLevel={jLogLevel}, HomePane={jHomePane}, HomeSelView={jHomeSelView}\n"); + File.AppendAllText(diagPath, $"[{DateTime.Now:O}] Load OBJ: LogLevel={(int)ac.Log.LogLevel}, HomePane={(ac.Pages.Home.IsNavigationViewPaneOpened ? "open" : "closed")}, HomeSelView={ac.Pages.Home.SelectedViewName}\n"); + } + File.AppendAllText(diagPath, $"[{DateTime.Now:O}] Load<{typeof(T).Name}> SUCCESS, json={json.Length} bytes\n"); + return config; } catch (Exception ex) { - Log.Error(ex, "Error loading config file {FileName}: {Message}", fileName, ex.Message); + File.AppendAllText(diagPath, $"[{DateTime.Now:O}] Load<{typeof(T).Name}> EXCEPTION: {ex.GetType().Name}: {ex.Message}\nInner: {ex.InnerException?.GetType().Name}: {ex.InnerException?.Message}\nJSON preview: {(File.Exists(fullPath) ? File.ReadAllText(fullPath)[..Math.Min(500, (int)new FileInfo(fullPath).Length)] : "FILE NOT FOUND")}\n"); return new T(); } } diff --git a/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigManager.cs b/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigManager.cs index dff62241..17904d09 100644 --- a/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigManager.cs @@ -38,6 +38,11 @@ public class ConfigManager : IConfigService, IDisposable private readonly IConfigLoader _loader; private readonly IConfigSaver _saver; + /// + /// Whether Load() has been called at least once. SaveAll() is deferred until after Load. + /// + private bool _loaded; + /// /// Whether hot-reload is enabled /// @@ -114,22 +119,30 @@ public ConfigManager SetLocation(string location) /// public void Load() { - Log.Debug($"[ConfigManager] Load() called on instance {GetHashCode()}, _configLocation currently: {_configLocation}"); + var diagPath = Path.Combine(Path.GetFullPath("./Config/"), "ConfigLoadTrail.log"); + File.AppendAllText(diagPath, $"[{DateTime.Now:O}] ConfigManager.Load() START, _configLocation={_configLocation ?? "null"}\n"); + // Step 1: Check if _configLocation is set if (string.IsNullOrEmpty(_configLocation)) { - Log.Debug($"[ConfigManager] _configLocation is null/empty, setting default location"); + Log.Information($"[ConfigManager] _configLocation is null/empty, setting default"); SetLocation("./Config/"); } - AppConfig = _loader.Load(_configLocation, "AppConfig.json"); + Log.Information($"[ConfigManager] Loading configs from: {_configLocation}"); + AppConfig = _loader.Load(_configLocation!, "AppConfig.json"); PluginsConfig = _loader.Load(_configLocation, "PluginsConfig.json"); SecurityConfig = _loader.LoadSecurityConfig(_configLocation); + Log.Information($"[ConfigManager] Load complete — LogLevel={AppConfig.Log.LogLevel}, HomePane={(AppConfig.Pages.Home.IsNavigationViewPaneOpened ? "open" : "closed")}"); + _configs["AppConfig"] = AppConfig; _configs["PluginsConfig"] = PluginsConfig; _configs["SecurityConfig"] = SecurityConfig; + _loaded = true; + Log.Information("[ConfigManager] Load complete & SaveAll gate opened."); + if (HotReloadEnabled) { RegisterFileWatcher("AppConfig.json"); @@ -173,12 +186,14 @@ public void Load() return; } - Log.Information("FileWatcher {WatcherName}: File changed - {FileName}, {ChangeType}", - watcherName, args.Name, args.ChangeType); + Log.Information("[ConfigManager] FileWatcher {WatcherName}: Reloading config from disk", watcherName); try { ReloadConfigFile(fileName); + if (typeof(T) == typeof(AppConfig)) + Log.Information("[ConfigManager] FileWatcher: After reload, LogLevel={Level}", ((AppConfig)(object)_configs["AppConfig"]!).Log.LogLevel); + Log.Information("[ConfigManager] FileWatcher {WatcherName}: Reload complete", watcherName); OnConfigChanged(typeof(T).Name, "FileChanged", null, null); } catch (Exception ex) @@ -243,7 +258,16 @@ public void DecreaseExceptCount(string watcherName, int count = 1) /// public void SaveAll() { - Log.Debug($"[ConfigManager] SaveAll() called on instance {GetHashCode()}, _configLocation: {_configLocation}"); + var diagPath = Path.Combine(Path.GetFullPath("./Config/"), "ConfigLoadTrail.log"); + + if (!_loaded) + { + File.AppendAllText(diagPath, $"[{DateTime.Now:O}] ConfigManager.SaveAll() SKIPPED (not loaded yet), LogLevel={(int)AppConfig.Log.LogLevel}\n"); + return; + } + + File.AppendAllText(diagPath, $"[{DateTime.Now:O}] ConfigManager.SaveAll() START, LogLevel={(int)AppConfig.Log.LogLevel}\n"); + File.AppendAllText(diagPath, $" StackTrace:\n{Environment.StackTrace}\n"); if (string.IsNullOrEmpty(_configLocation)) { diff --git a/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigSaver.cs b/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigSaver.cs index 842ee69b..0778f200 100644 --- a/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigSaver.cs +++ b/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigSaver.cs @@ -28,9 +28,10 @@ public void Save(T config, string location, string fileName) where T : class } var jsonContent = JsonSerializer.Serialize(config, ConfigSerializationOptions.Options); - File.WriteAllText(path, jsonContent); + var fullPath = Path.GetFullPath(path); + File.WriteAllText(fullPath, jsonContent); - Log.Debug("Saved config file {FileName}", fileName); + Log.Information("[ConfigSaver] Saved {FileName} → {FullPath} ({Bytes} bytes)", fileName, fullPath, jsonContent.Length); } catch (Exception ex) { diff --git a/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigSerializationOptions.cs b/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigSerializationOptions.cs index 6adacd60..88d93f6d 100644 --- a/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigSerializationOptions.cs +++ b/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigSerializationOptions.cs @@ -10,7 +10,6 @@ internal static class ConfigSerializationOptions internal static readonly JsonSerializerOptions Options = new() { WriteIndented = true, - IncludeFields = true, PropertyNameCaseInsensitive = true }; } From 564b76a0ff6d8c29101ac87e7ffa1628f688c586 Mon Sep 17 00:00:00 2001 From: StarInk Date: Sun, 17 May 2026 06:53:04 +0200 Subject: [PATCH 069/110] =?UTF-8?q?=F0=9F=92=BE=F0=9F=A7=A9=20Feat,=20Refa?= =?UTF-8?q?ctor(CFG,=20Debug,=20Blueprint):=20=E9=87=8D=E6=9E=84CFG?= =?UTF-8?q?=E7=AE=A1=E7=BA=BF=E5=AE=9E=E7=8E=B0=E7=BB=9F=E4=B8=80Statement?= =?UTF-8?q?Id=E7=A9=BF=E9=80=8F=E4=B8=8E=E8=93=9D=E5=9B=BE=E9=80=90?= =?UTF-8?q?=E6=AD=A5=E8=B0=83=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CFGBuilderFromBlueprint 初始化 DebugContext 并填充 StatementId 身份映射 - ConvertBlockStatementToCfgStatement 补全 ExpressionStatement 解析:从策略获取 Kind/FunctionName,ExprUtils 提取 Arguments/SetVarName/GetVarName/PubVarTarget - ScriptGenerator.ConvertStatement 将 StatementId 从 CFGStatement 传播至 BlockStatement - ScriptFormatter.FormatFlowControl/FormatInvocation 将非空 StatementId 从 BlockStatement 回传至新 CFGStatement - 新增 CFGPipeline.cs 文档化 5 条子路径:BS2CFG/BP2CFG/CFG2BS/CFG2BP/CFG2CS - ScriptAssemblyCompiler 新增 CompileFromCFG(cfg, script, workflowId) 支持 BP→CFG→CS 直达编译 - BlockScriptExecutor 新增 ExecuteFromCFGAsync 实现直达执行 - BlueprintService.ExecuteBlueprintAsync 优先走 BP→CFG→CS 直达路径 - GenerateBlockStatement: 移除冗余 Break 双 case(退回到 BreakFunction.ToStatement) - GetFunction.ToStatement: 消费检查+PubVar查找+赋值包裹迁移自 GenerateGetStatement - GenerateGetStatement 方法及 Get 双 case 移除,INodeExportHelper 实现 GetOutputPubVar/IsOutputConsumed - BuiltinFunctionExportStrategyAdapter 透传 StatementKind - BlueprintDebugger.UpdateVariableSnapshot: 新增 key 始终触发 VariableChanged - ScriptCodeGenerator: PubVar 局部声明后追加 G.Set 同步至调试器快照 - BlockScriptExecutionGlobals.Set/SetGlobalVariable 联动 Debugger.UpdateVariableSnapshot - BluePrint Test 新增 Q(RunAsync+Debug模式执行)、R(未初始化变量编译) 测试 - 移除 SourceCFG 临时工作区 --- .../KitX.Core.BluePrint.Test/Program.cs | 223 +++++++++++++++++- .../BlockScriptExecutionGlobals.cs | 4 + .../BlockScripting/BlockScriptExecutor.cs | 85 ++++++- .../BlockScripting/BlueprintDebugger.cs | 123 ++++++++++ .../BuiltinFunctions/GetFunction.cs | 8 +- .../BlockScripting/ICompiledBlockScript.cs | 17 +- .../BlockScripting/ScriptAssemblyCompiler.cs | 68 ++++++ .../BlockScripting/ScriptCodeGenerator.cs | 104 ++++++-- .../Workflow/Blueprint/BlueprintService.cs | 37 ++- .../BlueprintToBlockScriptConverter.cs | 11 +- .../BuiltinFunctionExportStrategyAdapter.cs | 4 + .../Blueprint/CFG/BlueprintDebugContext.cs | 8 + .../Blueprint/CFG/CFGBuilderFromBlueprint.cs | 124 +++++----- .../Workflow/Blueprint/CFG/CFGPipeline.cs | 93 ++++++++ .../Blueprint/CFG/ControlFlowGraph.cs | 2 + .../Workflow/Blueprint/CFG/ScriptGenerator.cs | 19 +- .../Workflow/Blueprint/NodeExportHelper.cs | 8 + .../Blueprint/Pipeline/NodeBuilder.cs | 7 + .../Blueprint/Pipeline/ScriptFormatter.cs | 14 +- KitX Clients/KitX Dashboard | 2 +- KitX Standard | 2 +- 21 files changed, 864 insertions(+), 99 deletions(-) create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlueprintDebugger.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/BlueprintDebugContext.cs create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGPipeline.cs diff --git a/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs b/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs index ac669013..91f4f950 100644 --- a/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs +++ b/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Threading; @@ -196,6 +196,22 @@ public static void Main(string[] args) Console.WriteLine("└──────────────────────────────────────────┘\n"); RunBuiltinAssemblyTest(parser, sp); } + + if (ShouldRunTest("Q")) + { + Console.WriteLine("\n┌──────────────────────────────────────────┐"); + Console.WriteLine("│ Test Q: Debug Mode Execution │"); + Console.WriteLine("└──────────────────────────────────────────┘\n"); + RunDebugExecutionTest(parser, sp); + } + + if (ShouldRunTest("R")) + { + Console.WriteLine("\n┌──────────────────────────────────────────┐"); + Console.WriteLine("│ Test R: Uninitialized ConstBlock Var │"); + Console.WriteLine("└──────────────────────────────────────────┘\n"); + RunUninitializedVarTest(parser, sp); + } } private static void ParseArgs(string[] args) @@ -462,7 +478,7 @@ private static void RunAssemblyCompilationTest( using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); try { - compiled.Run(globals, cts.Token); + compiled.RunAsync(globals, cts.Token).GetAwaiter().GetResult(); Console.WriteLine($"[Test L] Assembly execution: SUCCESS"); Console.WriteLine($"[Test L] ExecutedBlockCount: {globals.ExecutedBlockCount}"); Console.WriteLine($"[Test L] Output: [{string.Join(", ", output)}]"); @@ -1056,7 +1072,7 @@ private static void RunCrossDevicePluginCallTest( globals.ResetRunState(); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); - compiled.Run(globals, cts.Token); + compiled.RunAsync(globals, cts.Token).GetAwaiter().GetResult(); Console.WriteLine($" Assembly execution completed"); Console.WriteLine($" Output ({output.Count} lines): [{string.Join(", ", output)}]"); @@ -1222,7 +1238,7 @@ private static void RunJsonGetFieldTest( using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); try { - compiled.Run(globals, cts.Token); + compiled.RunAsync(globals, cts.Token).GetAwaiter().GetResult(); Console.WriteLine($" Execution: SUCCESS"); Console.WriteLine($" Output ({output.Count} lines): [{string.Join(", ", output)}]"); @@ -1501,7 +1517,7 @@ private static void RunBuiltinAssemblyTest( using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); try { - compiled.Run(globals, cts.Token); + compiled.RunAsync(globals, cts.Token).GetAwaiter().GetResult(); Console.WriteLine($" Execution: OK, output ({output.Count}): [{string.Join(", ", output)}]"); bool outputOk = output.Any(o => o.Contains("Hello from builtin test")); @@ -1560,4 +1576,201 @@ private static void RunBuiltinAssemblyTest( Console.WriteLine($"\n[Test P] {(allPassed ? "PASS - All phases passed!" : "FAIL - See above for details")}"); } + + private static void RunDebugExecutionTest( + IBlockScriptParser parser, + System.IServiceProvider sp) + { + bool allPassed = true; + + Console.WriteLine("[Test Q1] Debug mode compilation + execution"); + try + { + var sourceCode = @"#ConstBlock +int counter = 0; +int max = 2; + +#MainBlock +Set(""counter"", 0); +NextBlock = ""LoopBlock""; + +#Block LoopBlock +Print(Get(""counter"")); +Set(""counter"", HelperFuncAdd(Get(""counter""), 1)); +NextBlock = Branch(HelperFuncCompare(""BLT"", Get(""counter""), max), ""LoopBlock"", ""EndBlock""); + +#Block EndBlock +Print(""Done"");"; + + var parseResult = parser.Parse(sourceCode); + if (!parseResult.IsSuccess || parseResult.Script == null) + { + Console.WriteLine($" Parse FAILED: {parseResult.ErrorMessage}"); + allPassed = false; + } + else + { + parseResult.Script.HelperFunctions = GetExecutionHelpers(); + + var debugger = new BlueprintDebugger(); + var hitStatements = new List(); + + debugger.NodeExecuting += (id) => + { + lock (hitStatements) { hitStatements.Add(id); } + }; + + var executor = sp.GetRequiredService(); + if (executor is BlockScriptExecutor bse) + { + bse.SetDebugger(debugger); + } + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var result = executor.ExecuteAsync(parseResult.Script, cancellationToken: cts.Token) + .GetAwaiter().GetResult(); + + Console.WriteLine($" Execution: {(result.IsSuccess ? "SUCCESS" : "FAILED")}"); + Console.WriteLine($" Debug checkpoints hit: {hitStatements.Count}"); + Console.WriteLine($" Output: [{string.Join(", ", result.Output)}]"); + + bool q1pass = result.IsSuccess && hitStatements.Count > 0 && + result.Output.SequenceEqual(new[] { "0", "1", "Done" }); + Console.WriteLine($"[Test Q1] {(q1pass ? "PASS" : "FAIL")}"); + if (!q1pass) allPassed = false; + + if (executor is BlockScriptExecutor bse2) + { + bse2.SetDebugger(null); + } + } + } + catch (Exception ex) + { + Console.WriteLine($"[Test Q1] FAILED: {ex.Message}\n {ex.StackTrace}"); + allPassed = false; + } + + Console.WriteLine("\n[Test Q2] Debug slow execution"); + try + { + var sourceCode = @"#MainBlock +Print(""a""); +Print(""b""); +Print(""c"");"; + + var parseResult = parser.Parse(sourceCode); + if (!parseResult.IsSuccess || parseResult.Script == null) + { + allPassed = false; + } + else + { + var debugger = new BlueprintDebugger(); + debugger.SetSpeed(ExecutionSpeed.Slow); + + var executor = sp.GetRequiredService(); + if (executor is BlockScriptExecutor bse) + { + bse.SetDebugger(debugger); + } + + var sw = System.Diagnostics.Stopwatch.StartNew(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var result = executor.ExecuteAsync(parseResult.Script, cancellationToken: cts.Token) + .GetAwaiter().GetResult(); + sw.Stop(); + + Console.WriteLine($" Execution: {(result.IsSuccess ? "SUCCESS" : "FAILED")}"); + Console.WriteLine($" Elapsed: {sw.ElapsedMilliseconds}ms (expected > 1000ms for 3 slow steps)"); + + bool q2pass = result.IsSuccess && sw.ElapsedMilliseconds > 1000; + Console.WriteLine($"[Test Q2] {(q2pass ? "PASS" : "FAIL")}"); + if (!q2pass) allPassed = false; + + if (executor is BlockScriptExecutor bse2) + { + bse2.SetDebugger(null); + } + } + } + catch (Exception ex) + { + Console.WriteLine($"[Test Q2] FAILED: {ex.Message}\n {ex.StackTrace}"); + allPassed = false; + } + + Console.WriteLine($"\n[Test Q] {(allPassed ? "PASS - All phases passed!" : "FAIL - See above for details")}"); + } + + private static void RunUninitializedVarTest( + IBlockScriptParser parser, + System.IServiceProvider sp) + { + bool allPassed = true; + + Console.WriteLine("[Test R1] Compilation with uninitialized ConstBlock variable"); + try + { + var sourceCode = @"#ConstBlock +int counter; +int max = 3; + +#MainBlock +Set(""counter"", 0); +NextBlock = ""Loop""; + +#Block Loop +Print(Get(""counter"")); +Set(""counter"", HelperFuncAdd(Get(""counter""), 1)); +NextBlock = Branch(HelperFuncCompare(""BLT"", Get(""counter""), max), ""Loop"", ""End""); + +#Block End +Print(""Done"");"; + + var parseResult = parser.Parse(sourceCode); + if (!parseResult.IsSuccess || parseResult.Script == null) + { + Console.WriteLine($" Parse FAILED: {parseResult.ErrorMessage}"); + allPassed = false; + } + else + { + parseResult.Script.HelperFunctions = GetExecutionHelpers(); + + var compiler = new ScriptAssemblyCompiler(); + var compiled = compiler.CompileScript(parseResult.Script); + Console.WriteLine($" Compilation: {(compiled != null ? "SUCCESS" : "FAILED")}"); + + if (compiled != null) + { + var output = new List(); + var scopeManager = new BlockScopeManager(); + var globals = new BlockScriptExecutionGlobals(scopeManager, output); + globals.ResetRunState(); + scopeManager.InitializeGlobalScope(parseResult.Script); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + compiled.RunAsync(globals, cts.Token).GetAwaiter().GetResult(); + + Console.WriteLine($" Output ({output.Count}): [{string.Join(", ", output)}]"); + bool ok = output.SequenceEqual(new[] { "0", "1", "2", "Done" }); + Console.WriteLine($"[Test R1] {(ok ? "PASS" : "FAIL - wrong output")}"); + if (!ok) allPassed = false; + } + else + { + Console.WriteLine("[Test R1] FAIL - compilation returned null"); + allPassed = false; + } + } + } + catch (Exception ex) + { + Console.WriteLine($"[Test R1] FAILED: {ex.Message}\n {ex.StackTrace}"); + allPassed = false; + } + + Console.WriteLine($"\n[Test R] {(allPassed ? "PASS - All phases passed!" : "FAIL - See above for details")}"); + } } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutionGlobals.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutionGlobals.cs index 1a714da6..807e8255 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutionGlobals.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutionGlobals.cs @@ -19,6 +19,8 @@ public partial class BlockScriptExecutionGlobals private readonly Dictionary _variables = new(); private readonly IPluginManager? _pluginManager; + public IBlueprintDebugController? Debugger { get; set; } + // ─── 内置属性 ─────────────────────────────────────────────── /// @@ -83,6 +85,7 @@ public void Set(string name, object? value) } _variables[name] = value; _scopeManager.SetVariable(name, value, global: false); + Debugger?.UpdateVariableSnapshot(GetAllVariables()); } /// @@ -97,6 +100,7 @@ public void SetGlobalVariable(string name, object? value) } _variables[name] = value; _scopeManager.SetVariable(name, value, global: true); + Debugger?.UpdateVariableSnapshot(GetAllVariables()); } /// diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutor.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutor.cs index 93afe82c..360c0184 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutor.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutor.cs @@ -7,6 +7,7 @@ using KitX.Core.Contract.Workflow; using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Workflow; +using KitX.Core.Workflow.Blueprint.CFG; using Serilog; namespace KitX.Core.Workflow.BlockScripting; @@ -23,6 +24,7 @@ public class BlockScriptExecutor : IBlockScriptExecutor private IPluginManager? _pluginManager; private string? _workflowId; private readonly ScriptAssemblyCompiler _assemblyCompiler = new(); + private IBlueprintDebugController? _debugger; /// /// Creates a new block script executor @@ -66,6 +68,12 @@ public void SetWorkflowId(string? workflowId) _workflowId = workflowId; } + public void SetDebugger(IBlueprintDebugController? debugger) + { + _debugger = debugger; + ScriptCodeGenerator.IsDebugMode = debugger != null; + } + /// /// Compiles a BlockScript and persists it to disk (without executing). /// Used for pre-compilation at workflow save time. @@ -106,6 +114,8 @@ public async Task ExecuteAsync( try { + ScriptCodeGenerator.IsDebugMode = _debugger != null; + // Full-script assembly compilation var compiled = _assemblyCompiler.CompileScript(script, _workflowId); if (compiled == null) @@ -122,6 +132,7 @@ public async Task ExecuteAsync( Log.Debug("[BlockScriptExecutor] Using assembly-compiled execution path"); _globals = new BlockScriptExecutionGlobals(_scopeManager, _output, _pluginManager); + _globals.Debugger = _debugger; _globals.ResetRunState(); _scopeManager.InitializeGlobalScope(script); @@ -132,7 +143,7 @@ public async Task ExecuteAsync( _globals.Set(p.Key, p.Value); } - compiled.Run(_globals, cancellationToken); + await compiled.RunAsync(_globals, cancellationToken); _stopwatch.Stop(); return new BlockScriptExecutionResult @@ -162,6 +173,78 @@ public async Task ExecuteAsync( } } + /// + /// Executes a BlockScript using a pre-built CFG (BP→CFG→CS direct path). + /// Skips the BS→CFG conversion, preserving StatementIds from the blueprint. + /// + internal async Task ExecuteFromCFGAsync( + BlockScript script, + ControlFlowGraph cfg, + Dictionary? parameters = null, + CancellationToken cancellationToken = default) + { + _stopwatch.Restart(); + _output = new List(); + + try + { + ScriptCodeGenerator.IsDebugMode = _debugger != null; + + var compiled = _assemblyCompiler.CompileFromCFG(cfg, script, _workflowId); + if (compiled == null) + { + _stopwatch.Stop(); + return new BlockScriptExecutionResult + { + IsSuccess = false, + ErrorMessage = "Script compilation failed", + ExecutionTimeMs = _stopwatch.ElapsedMilliseconds, + Output = _output + }; + } + + Log.Debug("[BlockScriptExecutor] Using BP→CFG→CS direct execution path"); + _globals = new BlockScriptExecutionGlobals(_scopeManager, _output, _pluginManager); + _globals.Debugger = _debugger; + _globals.ResetRunState(); + _scopeManager.InitializeGlobalScope(script); + + if (parameters != null) + { + foreach (var p in parameters) + _globals.Set(p.Key, p.Value); + } + + await compiled.RunAsync(_globals, cancellationToken); + + _stopwatch.Stop(); + return new BlockScriptExecutionResult + { + IsSuccess = true, + ExecutedBlockCount = _globals.ExecutedBlockCount, + ExecutionTimeMs = _stopwatch.ElapsedMilliseconds, + Output = _output + }; + } + catch (OperationCanceledException) + { + _stopwatch.Stop(); + throw; + } + catch (Exception ex) + { + _stopwatch.Stop(); + Log.Error(ex, "[BlockScriptExecutor] Error executing from CFG"); + return new BlockScriptExecutionResult + { + IsSuccess = false, + ErrorMessage = $"Execution error: {ex.Message}", + ExecutionTimeMs = _stopwatch.ElapsedMilliseconds, + Output = _output + }; + } + } + /// /// Validates a block script /// diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlueprintDebugger.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlueprintDebugger.cs new file mode 100644 index 00000000..dc8d76b5 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlueprintDebugger.cs @@ -0,0 +1,123 @@ +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using KitX.Core.Contract.Workflow; +using Serilog; + +namespace KitX.Core.Workflow.BlockScripting; + +public class BlueprintDebugger : IBlueprintDebugController +{ + private readonly HashSet _breakpoints = new(); + private readonly ConcurrentDictionary _variableSnapshot = new(); + private TaskCompletionSource? _stepSignal; + private volatile bool _isPaused; + private ExecutionSpeed _speed = ExecutionSpeed.RealTime; + + public event Action? NodeExecuting; + public event Action? NodeExecuted; + public event Action? BlockEntered; + public event Action? VariableChanged; + public event Action? ExecutionPaused; + public event Action? ExecutionResumed; + + public ExecutionSpeed Speed => _speed; + public bool IsPaused => _isPaused; + + public IReadOnlyDictionary CurrentVariableSnapshot => + new Dictionary(_variableSnapshot); + + public void SetBreakpoint(string nodeId) => _breakpoints.Add(nodeId); + public void RemoveBreakpoint(string nodeId) => _breakpoints.Remove(nodeId); + public void ClearBreakpoints() => _breakpoints.Clear(); + public bool HasBreakpoint(string nodeId) => _breakpoints.Contains(nodeId); + + public void Pause() + { + _isPaused = true; + Log.Debug("[BlueprintDebugger] Paused"); + } + + public void StepNext() + { + _isPaused = false; + Log.Debug("[BlueprintDebugger] StepNext - releasing signal"); + try { ExecutionResumed?.Invoke(); } catch { } + var signal = _stepSignal; + _stepSignal = null; + signal?.TrySetResult(); + } + + public void Continue() + { + _speed = ExecutionSpeed.RealTime; + _isPaused = false; + Log.Debug("[BlueprintDebugger] Continue - switching to RealTime and releasing signal"); + try { ExecutionResumed?.Invoke(); } catch { } + var signal = _stepSignal; + _stepSignal = null; + signal?.TrySetResult(); + } + + public void SetSpeed(ExecutionSpeed speed) + { + _speed = speed; + Log.Debug("[BlueprintDebugger] Speed set to {Speed}", speed); + } + + public void UpdateVariableSnapshot(Dictionary variables) + { + foreach (var kvp in variables) + { + var existed = _variableSnapshot.TryGetValue(kvp.Key, out var existing); + var oldValue = existed ? existing : null; + if (!existed || !Equals(oldValue, kvp.Value)) + { + _variableSnapshot[kvp.Key] = kvp.Value; + try { VariableChanged?.Invoke(kvp.Key, kvp.Value); } catch { } + } + } + } + + public async Task CheckpointAsync( + string statementId, string? blockName, + CancellationToken cancellationToken) + { + if (!string.IsNullOrEmpty(blockName)) + { + try { BlockEntered?.Invoke(blockName); } catch { } + } + + try { NodeExecuting?.Invoke(statementId); } catch { } + + var hasBreakpoint = _breakpoints.Contains(statementId); + + if (_speed == ExecutionSpeed.Slow) + { + try { await Task.Delay(500, cancellationToken); } + catch (OperationCanceledException) { return; } + } + + if (hasBreakpoint && !_isPaused) + { + Pause(); + } + + if (_speed == ExecutionSpeed.StepByStep || _isPaused) + { + Log.Debug("[BlueprintDebugger] Checkpoint paused at {StmtId}", statementId ?? "(block)"); + try { ExecutionPaused?.Invoke(); } catch { } + _stepSignal = new TaskCompletionSource(); + try + { + await _stepSignal.Task.WaitAsync(cancellationToken); + } + catch (OperationCanceledException) { return; } + } + + cancellationToken.ThrowIfCancellationRequested(); + + try { NodeExecuted?.Invoke(statementId); } catch { } + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/GetFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/GetFunction.cs index fbdc7da0..2620a2ea 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/GetFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/GetFunction.cs @@ -111,6 +111,12 @@ public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) { + if (!helper.IsOutputConsumed(node, "Value")) + return null; + + var pubVar = helper.GetOutputPubVar(node, "Value"); + if (pubVar == null) return null; + var varName = node switch { GetNode gn => gn.VarName, @@ -120,7 +126,7 @@ public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) return new ExpressionStatement { Expression = $"Get(\"{varName}\")", - SourceCode = $"Get(\"{varName}\");", + SourceCode = $"{pubVar} = Get(\"{varName}\");", LineNumber = 1 }; } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ICompiledBlockScript.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ICompiledBlockScript.cs index 0194bdc6..0b457bb3 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ICompiledBlockScript.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ICompiledBlockScript.cs @@ -1,21 +1,18 @@ using System.Threading; +using System.Threading.Tasks; using KitX.Core.Contract.Workflow; namespace KitX.Core.Workflow.BlockScripting; /// /// Represents a BlockScript compiled into a .NET assembly via Roslyn CSharpCompilation. -/// The Run method executes the entire script in a single JIT-compiled method call, -/// eliminating all per-block CSharpScript overhead. +/// The RunAsync method executes the entire script, using a +/// while(true) + switch(NextBlock) dispatcher that runs all blocks +/// until the script naturally terminates or is cancelled. +/// When a debugger is attached, the generated code includes checkpoint calls +/// between each statement for breakpoint/step/slow execution. /// public interface ICompiledBlockScript { - /// - /// Executes the compiled script using the provided globals and cancellation token. - /// The method contains a while(true) + switch(NextBlock) dispatcher that - /// runs all blocks until the script naturally terminates or is cancelled. - /// - /// Execution globals providing built-in functions and variable storage. - /// Token for cooperative cancellation. - void Run(BlockScriptExecutionGlobals globals, CancellationToken cancellationToken); + Task RunAsync(BlockScriptExecutionGlobals globals, CancellationToken cancellationToken); } \ No newline at end of file diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptAssemblyCompiler.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptAssemblyCompiler.cs index c20bae0d..f037f6eb 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptAssemblyCompiler.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptAssemblyCompiler.cs @@ -143,6 +143,74 @@ public ScriptAssemblyCompiler() } } + /// + /// Compiles a using a pre-built + /// (e.g., from BP→CFG conversion). Skips the BS→CFG formatting step, preserving + /// the original values for debug checkpoints. + /// + public ICompiledBlockScript? CompileFromCFG( + ControlFlowGraph cfg, BlockScript script, string? workflowId) + { + var baseHash = ScriptCompilationBackend.ComputeScriptHash(script); + var hash = ScriptCodeGenerator.IsDebugMode ? $"debug_{baseHash}" : baseHash; + + if (_cache.TryGetValue(hash, out var entry) && entry.IsAlive) + { + Log.Debug("[ScriptAssemblyCompiler] Memory cache hit for hash '{Hash}'", hash); + return entry.Instance; + } + + if (workflowId != null) + { + var diskInstance = _persistence.TryLoadFromDisk(workflowId, hash); + if (diskInstance != null) + { + Log.Debug("[ScriptAssemblyCompiler] Disk cache hit for hash '{Hash}'", hash); + return diskInstance; + } + } + + try + { + Log.Debug("[ScriptAssemblyCompiler] Compiling from pre-built CFG: {BlockCount} blocks", cfg.Blocks.Count); + + var context = new PipelineContext { Script = script }; + if (script.PubVarBlock != null) + { + foreach (var variable in script.PubVarBlock.Variables) + { + if (!context.PubVarNames.Contains(variable.Name)) + context.PubVarNames.Add(variable.Name); + } + } + var pubVarTypes = ScriptCodeGenerator.InferPubVarTypes(cfg, script.HelperFunctions, context); + + var compilationUnit = ScriptCodeGenerator.GenerateCompilationUnit(script, cfg, pubVarTypes, hash); + var assembly = ScriptCompilationBackend.CompileToAssembly(compilationUnit, hash); + if (assembly == null) return null; + + var alc = new CollectibleAssemblyLoadContext(hash); + var loadedAssembly = alc.LoadFromStream(assembly); + var typeName = $"KitX.Core.Workflow.BlockScripting.Generated.CompiledScript_{hash}"; + var scriptType = loadedAssembly.GetType(typeName); + if (scriptType == null) { alc.Unload(); return null; } + + var instance = (ICompiledBlockScript)Activator.CreateInstance(scriptType)!; + _cache[hash] = new CompiledScriptEntry(instance, alc); + + if (workflowId != null) + _persistence.SaveToDisk(workflowId, hash, assembly, typeName); + + Log.Debug("[ScriptAssemblyCompiler] CompileFromCFG success for hash '{Hash}'", hash); + return instance; + } + catch (Exception ex) + { + Log.Warning(ex, "[ScriptAssemblyCompiler] CompileFromCFG failed"); + return null; + } + } + /// /// Clears the compilation cache and unloads all cached assemblies. /// diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptCodeGenerator.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptCodeGenerator.cs index bc5f35e3..3238ef7f 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptCodeGenerator.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptCodeGenerator.cs @@ -29,6 +29,8 @@ internal static class ScriptCodeGenerator private static readonly BuiltinFunctionRegistry FunctionRegistry = BuiltinFunctionRegistry.Discover(typeof(ScriptCodeGenerator).Assembly); + public static bool IsDebugMode { get; set; } + // ────────────────────────────────────────────── // Type inference // ────────────────────────────────────────────── @@ -156,12 +158,17 @@ internal static CompilationUnitSyntax GenerateCompilationUnit( ParseName("KitX.Core.Workflow.BlockScripting.Generated")) .AddMembers(classDecl); + var usings = new List + { + UsingDirective(ParseName("System")), + UsingDirective(ParseName("System.Threading")), + UsingDirective(ParseName("System.Threading.Tasks")), + UsingDirective(ParseName("KitX.Core.Contract.Workflow")), + UsingDirective(ParseName("KitX.Core.Workflow.BlockScripting")) + }; + return CompilationUnit() - .AddUsings( - UsingDirective(ParseName("System")), - UsingDirective(ParseName("System.Threading")), - UsingDirective(ParseName("KitX.Core.Contract.Workflow")), - UsingDirective(ParseName("KitX.Core.Workflow.BlockScripting"))) + .AddUsings(usings.ToArray()) .AddMembers(nsDecl) .NormalizeWhitespace(); } @@ -329,9 +336,11 @@ internal static MethodDeclarationSyntax GenerateRunMethod( Block(whileBody))); return MethodDeclaration( - PredefinedType(Token(SyntaxKind.VoidKeyword)), - Identifier("Run")) - .AddModifiers(Token(SyntaxKind.PublicKeyword)) + ParseTypeName("System.Threading.Tasks.Task"), + Identifier("RunAsync")) + .AddModifiers( + Token(SyntaxKind.PublicKeyword), + Token(SyntaxKind.AsyncKeyword)) .WithParameterList(ParameterList(SeparatedList(new[] { Parameter(Identifier("G")) @@ -448,17 +457,32 @@ internal static List GenerateVariableInit(VariableDeclaration d /// internal static ExpressionSyntax FormatLiteralExpression(string type, object value) { + if (value is string s && string.IsNullOrEmpty(s)) + { + return type switch + { + "int" or "long" or "double" or "float" => LiteralExpression(SyntaxKind.NumericLiteralExpression, Literal(0)), + "bool" => LiteralExpression(SyntaxKind.FalseLiteralExpression), + "char" => LiteralExpression(SyntaxKind.CharacterLiteralExpression, Literal('\0')), + "string" => LiteralExpression(SyntaxKind.StringLiteralExpression, Literal("")), + _ => LiteralExpression(SyntaxKind.NullLiteralExpression) + }; + } + return type switch { "string" => LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(value.ToString()!)), "char" => LiteralExpression(SyntaxKind.CharacterLiteralExpression, Literal(char.Parse(value.ToString()!))), - "bool" => (bool)value - ? LiteralExpression(SyntaxKind.TrueLiteralExpression) - : LiteralExpression(SyntaxKind.FalseLiteralExpression), - "int" => LiteralExpression(SyntaxKind.NumericLiteralExpression, Literal((int)value)), - "long" => LiteralExpression(SyntaxKind.NumericLiteralExpression, Literal((long)value)), - "double" => LiteralExpression(SyntaxKind.NumericLiteralExpression, Literal((double)value)), - "float" => LiteralExpression(SyntaxKind.NumericLiteralExpression, Literal((float)value)), + "bool" => value is bool b + ? (b ? LiteralExpression(SyntaxKind.TrueLiteralExpression) + : LiteralExpression(SyntaxKind.FalseLiteralExpression)) + : (bool.Parse(value.ToString()!) + ? LiteralExpression(SyntaxKind.TrueLiteralExpression) + : LiteralExpression(SyntaxKind.FalseLiteralExpression)), + "int" => LiteralExpression(SyntaxKind.NumericLiteralExpression, Literal(Convert.ToInt32(value))), + "long" => LiteralExpression(SyntaxKind.NumericLiteralExpression, Literal(Convert.ToInt64(value))), + "double" => LiteralExpression(SyntaxKind.NumericLiteralExpression, Literal(Convert.ToDouble(value))), + "float" => LiteralExpression(SyntaxKind.NumericLiteralExpression, Literal(Convert.ToSingle(value))), _ => ParseExpression(value?.ToString() ?? "null") }; } @@ -513,10 +537,22 @@ internal static SwitchSectionSyntax GenerateFormattedBlockCase( MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, IdentifierName("G"), IdentifierName("ExecutedBlockCount"))))); + if (IsDebugMode) + { + caseStatements.Add(GenerateDebugCheckpoint(null, block.Name)); + } + var hasNextBlockAssignment = false; + var stmtIndex = 0; foreach (var stmt in block.Statements) { + if (IsDebugMode) + { + caseStatements.Add(GenerateDebugCheckpoint(stmt.StatementId, null)); + } + stmtIndex++; + switch (stmt.Kind) { case CFGStatementKind.Assignment: @@ -568,6 +604,18 @@ internal static SwitchSectionSyntax GenerateFormattedBlockCase( VariableDeclaration(ParseTypeName(typeName)) .AddVariables(VariableDeclarator(Identifier(stmt.PubVarTarget)) .WithInitializer(EqualsValueClause(initExpr))))); + + // Sync PubVar to globals so debugger sees the value + caseStatements.Add(ExpressionStatement( + InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("G"), IdentifierName("Set")), + ArgumentList(SeparatedList(new[] + { + Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, + Literal(stmt.PubVarTarget))), + Argument(IdentifierName(stmt.PubVarTarget)) + }))))); } else { @@ -870,4 +918,30 @@ private static void GenerateFlowControl(string methodName, CFGStatement stmt, hasNextBlockAssignment = true; caseStatements.Add(BreakStatement()); } + + private static StatementSyntax GenerateDebugCheckpoint( + string? statementId, string? blockName) + { + var awaitExpr = AwaitExpression( + InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("G"), IdentifierName("Debugger")), + IdentifierName("CheckpointAsync")), + ArgumentList(SeparatedList(new[] + { + Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, + Literal(statementId ?? ""))), + Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, + Literal(blockName ?? ""))), + Argument(IdentifierName("ct")) + })))); + + var nullCheck = BinaryExpression(SyntaxKind.NotEqualsExpression, + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("G"), IdentifierName("Debugger")), + LiteralExpression(SyntaxKind.NullLiteralExpression)); + + return IfStatement(nullCheck, ExpressionStatement(awaitExpr)); + } } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintService.cs index 74fc8038..9511894f 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintService.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintService.cs @@ -73,8 +73,25 @@ public async Task ExecuteBlueprintAsync(Contract.Wor { Log.Information("Executing Blueprint"); var blockScript = _toBlockScriptConverter.ConvertToBlockScript(blueprint); - var result = await _executor.ExecuteAsync(blockScript, null, CancellationToken.None); - return result; + + // BP→CFG→CS direct path: use the pre-built CFG to avoid redundant BS→CFG conversion + // and preserve StatementId = node.Id for debug checkpoint highlighting. + if (_toBlockScriptConverter is BlueprintToBlockScriptConverter concrete + && concrete.LastCFG != null + && _executor is BlockScripting.BlockScriptExecutor bse) + { + Log.Debug("[BlueprintService] Using BP→CFG→CS direct path"); + var result = await bse.ExecuteFromCFGAsync(blockScript, concrete.LastCFG, null, CancellationToken.None); + + if (concrete.LastCFG.DebugContext != null) + result.DebugNodeMapping = new Dictionary(concrete.LastCFG.DebugContext.StatementToNodeId); + + return result; + } + + // Fallback: BS→CS path + var fallbackResult = await _executor.ExecuteAsync(blockScript, null, CancellationToken.None); + return fallbackResult; } catch (Exception ex) { @@ -86,4 +103,20 @@ public async Task ExecuteBlueprintAsync(Contract.Wor }; } } + + public Dictionary GetDebugNodeMapping(Contract.Workflow.Blueprint blueprint) + { + try + { + var blockScript = _toBlockScriptConverter.ConvertToBlockScript(blueprint); + if (_toBlockScriptConverter is BlueprintToBlockScriptConverter concrete && concrete.LastCFG?.DebugContext != null) + return new Dictionary(concrete.LastCFG.DebugContext.StatementToNodeId); + return new Dictionary(); + } + catch (Exception ex) + { + Log.Error(ex, "Failed to build debug node mapping"); + return new Dictionary(); + } + } } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintToBlockScriptConverter.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintToBlockScriptConverter.cs index efd9c189..f2e1d308 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintToBlockScriptConverter.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintToBlockScriptConverter.cs @@ -85,12 +85,15 @@ public BlockScript ConvertToBlockScript(Contract.Workflow.Blueprint blueprint) script.SourceCode = _scriptSerializer.Serialize(script); // Transfer helper functions from Blueprint to BlockScript - // (they were stored in Blueprint during BS→BP forward conversion but were - // previously lost in the BP→BS reverse conversion, causing UnknownMethodName) script.HelperFunctions = blueprint.HelperFunctions ?? []; - Log.Debug("[BlueprintToScript] Done. Source code length: {Len}, HelperFunctions: {Count}", - script.SourceCode?.Length ?? 0, script.HelperFunctions?.Count ?? 0); + // Preserve debug mapping for the execution pipeline + if (cfg.DebugContext != null) + script.DebugNodeMapping = new Dictionary(cfg.DebugContext.StatementToNodeId); + + Log.Debug("[BlueprintToScript] Done. Source code length: {Len}, HelperFunctions: {Count}, DebugMapping: {Map}", + script.SourceCode?.Length ?? 0, script.HelperFunctions?.Count ?? 0, + script.DebugNodeMapping?.Count ?? 0); return script; } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BuiltinFunctionExportStrategyAdapter.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BuiltinFunctionExportStrategyAdapter.cs index 8db119ad..8ddad021 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BuiltinFunctionExportStrategyAdapter.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BuiltinFunctionExportStrategyAdapter.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using KitX.Core.Contract.Workflow; using KitX.Core.Workflow.BlockScripting; +using KitX.Core.Workflow.Blueprint.CFG; namespace KitX.Core.Workflow.Blueprint; @@ -26,6 +27,9 @@ public BuiltinFunctionExportStrategyAdapter(IBuiltinFunctionDefinition definitio /// 代理底层定义的 LegacyNodeType,用于反向映射时识别具体的蓝图节点类型。 public BlueprintNodeType? LegacyNodeType => _definition.LegacyNodeType; + /// 代理底层定义的 StatementKind,用于 BP→CFG 时直接填充 CFGStatement 字段。 + public CFGStatementKind StatementKind => _definition.StatementKind; + /// public bool IsControlFlow => _definition.IsFlowControl; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/BlueprintDebugContext.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/BlueprintDebugContext.cs new file mode 100644 index 00000000..4046047d --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/BlueprintDebugContext.cs @@ -0,0 +1,8 @@ +using System.Collections.Generic; + +namespace KitX.Core.Workflow.Blueprint.CFG; + +public class BlueprintDebugContext +{ + public Dictionary StatementToNodeId { get; } = new(); +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGBuilderFromBlueprint.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGBuilderFromBlueprint.cs index b0951f6d..ad536ce5 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGBuilderFromBlueprint.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGBuilderFromBlueprint.cs @@ -2,6 +2,9 @@ using System.Linq; using KitX.Core.Contract.Workflow; using KitX.Core.Workflow.BlockScripting; +using KitX.Core.Workflow.Blueprint; +using KitX.Core.Workflow.Blueprint.Pipeline; +using Microsoft.CodeAnalysis.CSharp.Syntax; using Serilog; using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Pins; @@ -39,7 +42,10 @@ public CFGBuilderFromBlueprint( /// public ControlFlowGraph Build(Contract.Workflow.Blueprint blueprint) { - var cfg = new ControlFlowGraph(); + var cfg = new ControlFlowGraph + { + DebugContext = new BlueprintDebugContext() + }; // ── Step 1: Index and classify all nodes and connections ── var nodeById = new Dictionary(); @@ -123,6 +129,20 @@ public ControlFlowGraph Build(Contract.Workflow.Blueprint blueprint) Log.Debug("[CFGBuilderFromBlueprint] Built CFG: {BlockCount} blocks, {EdgeCount} edges", cfg.Blocks.Count, cfg.Blocks.Sum(b => b.Successors.Count)); + // ── Step 9: Populate debug node mapping ── + if (cfg.DebugContext != null && cfg.Blocks != null) + { + foreach (var block in cfg.Blocks) + { + if (block?.Statements == null) continue; + foreach (var stmt in block.Statements) + { + if (stmt != null && !string.IsNullOrEmpty(stmt.StatementId)) + cfg.DebugContext.StatementToNodeId[stmt.StatementId] = stmt.StatementId; + } + } + } + return cfg; } @@ -782,24 +802,6 @@ private static void SetParentLoopReferences(ControlFlowGraph cfg) case BlueprintNodeType.Entry: case BlueprintNodeType.PluginTrigger: return null; - case BlueprintNodeType.Break: - return new FlowControlStatement - { - ControlType = FlowControlType.Break, - SourceCode = "Break();", - LineNumber = 1 - }; - case BlueprintNodeType.BuiltinFunction when IsBreakNode(node): - return new FlowControlStatement - { - ControlType = FlowControlType.Break, - SourceCode = "Break();", - LineNumber = 1 - }; - case BlueprintNodeType.Get: - return GenerateGetStatement(node); - case BlueprintNodeType.BuiltinFunction when IsGetNode(node): - return GenerateGetStatement(node); case BlueprintNodeType.Call: { if (node is not CallNode call) return null; @@ -890,29 +892,6 @@ private void PostProcessCallReturn(BlueprintNode node, BlockStatement stmt) } } - private BlockStatement? GenerateGetStatement(BlueprintNode node) - { - if (_currentCtx == null) return null; - - // Only generate a Get statement if the Get node's output is consumed - if (!_currentCtx.ConsumedOutputs.Contains((node.Id, Value))) - return null; - - var pubVar = NodeExportHelper.FindOutputPubVar(node, Value, _currentCtx); - if (pubVar == null) return null; - - if (!_strategies.TryGetValue(BlueprintNodeType.Get, out var strategy)) - return null; - - var stmt = strategy.ToStatement(node, _exportHelper); - if (stmt is ExpressionStatement exprStmt) - { - exprStmt.SourceCode = $"{pubVar} = {exprStmt.Expression};"; - return exprStmt; - } - return stmt; - } - private ConversionContext? _currentCtx; /// @@ -929,7 +908,7 @@ public void SetContext(Contract.Workflow.Blueprint blueprint, ConversionContext? // Statement Conversion Helpers // ════════════════════════════════════════════════════════════════════ - private static CFGStatement ConvertBlockStatementToCfgStatement(BlockStatement blockStmt, BlueprintNode node) + private CFGStatement ConvertBlockStatementToCfgStatement(BlockStatement blockStmt, BlueprintNode node) { var cfgStmt = new CFGStatement { @@ -956,14 +935,57 @@ private static CFGStatement ConvertBlockStatementToCfgStatement(BlockStatement b break; case ExpressionStatement expr: - cfgStmt.Kind = CFGStatementKind.Expression; cfgStmt.OriginalExpression = expr.SourceCode; - // Parse PubVar target from "pubVar = expr;" pattern - var sourceCode = expr.SourceCode; - var eqIdx = sourceCode.IndexOf(" = "); - if (eqIdx > 0 && sourceCode.StartsWith(expr.Expression.Split('.')[0])) + + // Kind & FunctionName from strategy (built-in function metadata) + if (node is BuiltinFunctionNode bfn + && _builtinFunctionStrategies.TryGetValue(bfn.FunctionName, out var bfStrat) + && bfStrat is BuiltinFunctionExportStrategyAdapter bfAdapter) { - cfgStmt.PubVarTarget = sourceCode[..eqIdx]; + cfgStmt.Kind = bfAdapter.StatementKind; + cfgStmt.FunctionName = bfAdapter.FunctionName; + } + else if (_strategies.TryGetValue(node.NodeType, out var strat) + && strat is BuiltinFunctionExportStrategyAdapter adapter) + { + cfgStmt.Kind = adapter.StatementKind; + cfgStmt.FunctionName = adapter.FunctionName; + } + else + { + cfgStmt.Kind = CFGStatementKind.Expression; + } + + // Arguments & PubVarTarget from SourceCode (generated from node data in same pass) + var parsed = ExprUtils.ParseStatement(expr.SourceCode); + if (parsed?.rightExpr is InvocationExpressionSyntax invoke) + { + cfgStmt.FunctionName ??= ExprUtils.GetMethodName(invoke); + cfgStmt.Arguments = invoke.ArgumentList.Arguments + .Select(a => a.Expression.ToString()).ToList(); + if (parsed.Value.assignedVar != null) + cfgStmt.PubVarTarget = parsed.Value.assignedVar; + + // Extract GetVarName / SetVarName from string literal first argument + if (cfgStmt.Arguments.Count > 0) + { + var firstArg = invoke.ArgumentList.Arguments[0].Expression; + var strVal = ExprUtils.GetStringLiteralValue(firstArg); + if (!string.IsNullOrEmpty(strVal)) + { + if (cfgStmt.FunctionName == "Set") + { + cfgStmt.SetVarName = strVal; + // Remove var name from Arguments (matching FormatInvocation behaviour) + cfgStmt.Arguments.RemoveAt(0); + } + else if (cfgStmt.FunctionName == "Get") + { + cfgStmt.GetVarName = strVal; + cfgStmt.Arguments.RemoveAt(0); + } + } + } } break; @@ -1172,8 +1194,4 @@ private static bool IsNodeType(BlueprintNode node, BlueprintNodeType type) => private static bool IsBranchNode(BlueprintNode node) => IsNodeType(node, BlueprintNodeType.Branch); private static bool IsLoopNode(BlueprintNode node) => IsNodeType(node, BlueprintNodeType.Loop); - - private static bool IsBreakNode(BlueprintNode node) => IsNodeType(node, BlueprintNodeType.Break); - - private static bool IsGetNode(BlueprintNode node) => IsNodeType(node, BlueprintNodeType.Get); } \ No newline at end of file diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGPipeline.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGPipeline.cs new file mode 100644 index 00000000..8d8e1172 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGPipeline.cs @@ -0,0 +1,93 @@ +using KitX.Core.Contract.Workflow; +using KitX.Core.Workflow.BlockScripting; +using KitX.Core.Workflow.Blueprint.Pipeline; + +namespace KitX.Core.Workflow.Blueprint.CFG; + +/// +/// Canonical CFG pipeline: five sub-path functions that compose into four main paths. +/// +/// Sub-paths: +/// BS2CFG(BlockScript) → ControlFlowGraph (parse, expand syntax sugar, allocate IDs) +/// BP2CFG(Blueprint) → ControlFlowGraph (build from nodes, StatementId = node.Id) +/// CFG2BS(ControlFlowGraph) → BlockScript (serialize, preserve StatementId) +/// CFG2BP(ControlFlowGraph) → (via PipelineContext) (build visual nodes) +/// CFG2CS(ControlFlowGraph, …) → CompilationUnitSyntax (generate C#, emit debug checkpoints) +/// +/// Main paths: +/// BS→BP = CFG2BP(BS2CFG(bs)) +/// BP→BS = CFG2BS(BP2CFG(bp)) +/// BS→CS = compile(CFG2CS(BS2CFG(bs))) +/// BP→CS = compile(CFG2CS(BP2CFG(bp))) +/// +/// StatementId flow: +/// BS2CFG — empty BlockStatement.StatementId → generates new Guid +/// BP2CFG — sets StatementId = node.Id ✓ +/// CFG2BS — copies StatementId to BlockStatement ✓ +/// BS2CFG (from BP→BS) — preserves non-empty StatementId ✓ +/// CFG2CS — uses stmt.StatementId for debug checkpoints ✓ +/// CFG2BP — uses stmt.StatementId for NodeByStatementId mapping ✓ +/// +internal static class CFGPipeline +{ + /// + /// BS → CFG: parse source code, expand syntax sugar, allocate IDs. + /// Equivalent to ScriptFormatter.Format(). + /// + internal static ControlFlowGraph BS2CFG( + BlockScript script, + List helpers, + BuiltinFunctionRegistry functionRegistry) + { + var context = new PipelineContext { Script = script }; + var formatter = new ScriptFormatter(helpers, functionRegistry); + var cfg = formatter.Format(script, context); + cfg.DebugContext = new BlueprintDebugContext(); + return cfg; + } + + /// + /// BP → CFG: build from Blueprint nodes, StatementId = node.Id. + /// Equivalent to CFGBuilderFromBlueprint.Build(). + /// + internal static ControlFlowGraph BP2CFG( + Contract.Workflow.Blueprint blueprint, + Dictionary strategyMap, + Dictionary builtinMap, + NodeExportHelper exportHelper) + { + var builder = new CFGBuilderFromBlueprint(strategyMap, builtinMap, exportHelper); + builder.SetContext(blueprint, new ConversionContext + { + Blueprint = blueprint, + Script = new BlockScript() + }); + return builder.Build(blueprint); + } + + /// + /// CFG → BS: serialize to BlockScript, preserving StatementId. + /// Equivalent to ScriptGenerator.Generate(). + /// + internal static BlockScript CFG2BS(ControlFlowGraph cfg) + { + var generator = new ScriptGenerator(); + var script = generator.Generate(cfg); + return script; + } + + /// + /// CFG → BP: build visual Blueprint nodes. + /// Equivalent to NodeBuilder.Build(). + /// + internal static void CFG2BP( + ControlFlowGraph cfg, + PipelineContext context, + INodeRegistry registry, + List helpers, + BuiltinFunctionRegistry? functionRegistry = null) + { + var nodeBuilder = new NodeBuilder(registry, helpers, functionRegistry); + nodeBuilder.Build(cfg, context); + } +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/ControlFlowGraph.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/ControlFlowGraph.cs index 8d7af0d6..e14b2292 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/ControlFlowGraph.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/ControlFlowGraph.cs @@ -46,6 +46,8 @@ public class ControlFlowGraph /// public int PubVarCounter { get; set; } = 1; + public BlueprintDebugContext? DebugContext { get; set; } + /// /// Gets a block by name. Returns null if not found. /// diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/ScriptGenerator.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/ScriptGenerator.cs index b7e714e0..96cb473b 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/ScriptGenerator.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/ScriptGenerator.cs @@ -106,11 +106,14 @@ public BlockScript Generate(ControlFlowGraph cfg) private static BlockStatement? ConvertStatement(CFGStatement cfgStmt) { + BlockStatement? result = null; + switch (cfgStmt.Kind) { case CFGStatementKind.Branch: return new FlowControlStatement { + StatementId = cfgStmt.StatementId, ControlType = FlowControlType.Branch, ConditionExpression = cfgStmt.ConditionExpression ?? string.Empty, TrueBlockName = cfgStmt.TrueBlockName ?? string.Empty, @@ -118,10 +121,12 @@ public BlockScript Generate(ControlFlowGraph cfg) SourceCode = cfgStmt.OriginalExpression, LineNumber = cfgStmt.SourceLine }; + break; case CFGStatementKind.Loop: return new FlowControlStatement { + StatementId = cfgStmt.StatementId, ControlType = FlowControlType.Loop, ConditionExpression = cfgStmt.ConditionExpression ?? string.Empty, TrueBlockName = cfgStmt.TrueBlockName ?? string.Empty, @@ -129,23 +134,28 @@ public BlockScript Generate(ControlFlowGraph cfg) SourceCode = cfgStmt.OriginalExpression, LineNumber = cfgStmt.SourceLine }; + break; case CFGStatementKind.ToLoopCond: return new FlowControlStatement { + StatementId = cfgStmt.StatementId, ControlType = FlowControlType.ToLoopCond, ToLoopCondReturnTo = cfgStmt.ToLoopCondReturnTo, SourceCode = cfgStmt.OriginalExpression, LineNumber = cfgStmt.SourceLine }; + break; case CFGStatementKind.Break: return new FlowControlStatement { + StatementId = cfgStmt.StatementId, ControlType = FlowControlType.Break, SourceCode = cfgStmt.OriginalExpression, LineNumber = cfgStmt.SourceLine }; + break; case CFGStatementKind.Print: case CFGStatementKind.Pause: @@ -155,28 +165,31 @@ public BlockScript Generate(ControlFlowGraph cfg) case CFGStatementKind.Expression: return new ExpressionStatement { + StatementId = cfgStmt.StatementId, Expression = ExtractExpression(cfgStmt.OriginalExpression), SourceCode = cfgStmt.OriginalExpression, LineNumber = cfgStmt.SourceLine }; + break; case CFGStatementKind.NextBlockAssignment: - // NextBlock assignments are handled via block.NextBlockName, not as statements return null; default: - // Unknown statement — emit as ExpressionStatement if (!string.IsNullOrEmpty(cfgStmt.OriginalExpression)) { return new ExpressionStatement { + StatementId = cfgStmt.StatementId, Expression = ExtractExpression(cfgStmt.OriginalExpression), SourceCode = cfgStmt.OriginalExpression, LineNumber = cfgStmt.SourceLine }; } - return null; + break; } + + return result; } /// diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeExportHelper.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeExportHelper.cs index 658704a8..442295ce 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeExportHelper.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeExportHelper.cs @@ -54,6 +54,14 @@ public string GetInputArgs(BlueprintNode node) return string.Join(", ", args); } + /// + public string? GetOutputPubVar(BlueprintNode node, string pinName) + => _currentCtx != null ? FindOutputPubVar(node, pinName, _currentCtx) : null; + + /// + public bool IsOutputConsumed(BlueprintNode node, string pinName) + => _currentCtx?.ConsumedOutputs.Contains((node.Id, pinName)) == true; + /// /// Resolves an input pin's value using the pre-built InputDataMap (for topology path). /// diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/NodeBuilder.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/NodeBuilder.cs index 52f9bff4..1829993b 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/NodeBuilder.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/NodeBuilder.cs @@ -51,6 +51,13 @@ public void Build(ControlFlowGraph script, PipelineContext context) // Resolve cross-block edges ResolveCrossBlockEdges(context); + // Populate debug context: StatementId → NodeId mapping + if (script.DebugContext != null) + { + foreach (var kvp in context.NodeByStatementId) + script.DebugContext.StatementToNodeId[kvp.Key] = kvp.Value.Id; + } + Log.Debug("[NodeBuilder] Done: {NodeCount} nodes, {ExecEdgeCount} exec edges", context.AllNodes.Count, context.ExecEdges.Count); } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ScriptFormatter.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ScriptFormatter.cs index 3cf7dd42..6d4d9e9b 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ScriptFormatter.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ScriptFormatter.cs @@ -117,7 +117,8 @@ private List FormatFlowControl(FlowControlStatement flowCtrl, stri TrueBlockName = flowCtrl.TrueBlockName, FalseBlockName = flowCtrl.FalseBlockName, OriginalExpression = flowCtrl.SourceCode, - SourceLine = flowCtrl.LineNumber + SourceLine = flowCtrl.LineNumber, + StatementId = !string.IsNullOrEmpty(flowCtrl.StatementId) ? flowCtrl.StatementId : Guid.NewGuid().ToString() }); } break; @@ -129,6 +130,7 @@ private List FormatFlowControl(FlowControlStatement flowCtrl, stri var loopStmt = new CFGStatement { + StatementId = !string.IsNullOrEmpty(flowCtrl.StatementId) ? flowCtrl.StatementId : Guid.NewGuid().ToString(), BlockName = blockName, Kind = CFGStatementKind.Loop, FunctionName = Loop, @@ -157,6 +159,7 @@ private List FormatFlowControl(FlowControlStatement flowCtrl, stri case FlowControlType.ToLoopCond: result.Add(new CFGStatement { + StatementId = !string.IsNullOrEmpty(flowCtrl.StatementId) ? flowCtrl.StatementId : Guid.NewGuid().ToString(), BlockName = blockName, Kind = CFGStatementKind.ToLoopCond, FunctionName = ToLoopCond, @@ -169,6 +172,7 @@ private List FormatFlowControl(FlowControlStatement flowCtrl, stri case FlowControlType.Break: result.Add(new CFGStatement { + StatementId = !string.IsNullOrEmpty(flowCtrl.StatementId) ? flowCtrl.StatementId : Guid.NewGuid().ToString(), BlockName = blockName, Kind = CFGStatementKind.Break, FunctionName = Break, @@ -212,7 +216,8 @@ private List FormatExpressionStatement(ExpressionStatement exprStm return result; var fullFuncName = ExprUtils.GetFullMethodName(invoke); - result.AddRange(FormatInvocation(invoke, funcName, blockName, context, assignedVar, fullFuncName)); + result.AddRange(FormatInvocation(invoke, funcName, blockName, context, assignedVar, fullFuncName, + statementId: exprStmt.StatementId)); return result; } @@ -225,7 +230,8 @@ private List FormatExpressionStatement(ExpressionStatement exprStm /// private List FormatInvocation( InvocationExpressionSyntax invoke, string funcName, string blockName, - PipelineContext context, string? assignedVar, string? fullFuncName = null) + PipelineContext context, string? assignedVar, string? fullFuncName = null, + string? statementId = null) { var result = new List(); @@ -284,6 +290,8 @@ private List FormatInvocation( result.Add(new CFGStatement { + StatementId = !string.IsNullOrEmpty(statementId) ? statementId + : Guid.NewGuid().ToString(), BlockName = blockName, Kind = kind, FunctionName = funcName, diff --git a/KitX Clients/KitX Dashboard b/KitX Clients/KitX Dashboard index 52ab8972..0e03058f 160000 --- a/KitX Clients/KitX Dashboard +++ b/KitX Clients/KitX Dashboard @@ -1 +1 @@ -Subproject commit 52ab89728c3e664700583134bac392fc0b1e16b3 +Subproject commit 0e03058f7e22bbcc9fc693e94dca8b1b56b64423 diff --git a/KitX Standard b/KitX Standard index ddd94f7e..8d1243f4 160000 --- a/KitX Standard +++ b/KitX Standard @@ -1 +1 @@ -Subproject commit ddd94f7efd13cc6b60bf2109047c4b8134dfa2e6 +Subproject commit 8d1243f4ade2eaf3e6a59f5624702c43191579d5 From 6acf5532f9a9c49c17922c139e5de88ed1987e4b Mon Sep 17 00:00:00 2001 From: StarInk Date: Sun, 17 May 2026 20:26:16 +0200 Subject: [PATCH 070/110] =?UTF-8?q?=F0=9F=A7=A9=20Refactor(CFG):=20?= =?UTF-8?q?=E5=AF=B9CFG=E7=B3=BB=E7=BB=9F=E6=95=B4=E7=90=86=E3=80=81?= =?UTF-8?q?=E7=B2=BE=E7=AE=80=E5=86=97=E4=BD=99=E4=BB=A3=E7=A0=81=E5=92=8C?= =?UTF-8?q?=E6=AD=BB=E4=BB=A3=E7=A0=81/=E6=97=A7=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E3=80=81=E9=87=8D=E5=91=BD=E5=90=8D=E7=9B=B8=E5=85=B3=E7=B1=BB?= =?UTF-8?q?=E4=B8=8E=E6=A8=A1=E5=9D=97=E4=BD=BF=E5=85=B6=E6=9B=B4=E6=98=93?= =?UTF-8?q?=E8=AF=BB=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../KitX.Core.BluePrint.Test/Program.cs | 14 ++-- .../BlockScripting/BlockScriptExecutor.cs | 8 +-- .../PluginCallWithTargetFunction.cs | 2 +- ...iptCodeGenerator.cs => CFG2CSGenerator.cs} | 8 +-- ...criptAssemblyCompiler.cs => CSCompiler.cs} | 64 ++++++++---------- .../IBuiltinFunctionDefinition.cs | 12 ++-- .../BlockScriptToBlueprintConverter.cs | 14 ++-- .../BlueprintToBlockScriptConverter.cs | 47 +++++-------- ...derFromBlueprint.cs => BP2CFGConverter.cs} | 67 ++++++++++--------- ...Serializer.cs => BlockScriptSerializer.cs} | 4 +- ...{ScriptGenerator.cs => CFG2BSConverter.cs} | 4 +- .../Blueprint/CFG/CFGBuilderFromScript.cs | 10 +-- .../Workflow/Blueprint/CFG/CFGPipeline.cs | 39 ++++++----- ...{ScriptFormatter.cs => BS2CFGConverter.cs} | 6 +- .../{NodeBuilder.cs => CFG2BPConverter.cs} | 8 +-- .../Workflow/Blueprint/Pipeline/ExprUtils.cs | 51 -------------- .../Blueprint/Pipeline/PipelineContext.cs | 4 +- KitX Clients/KitX Dashboard | 2 +- 18 files changed, 150 insertions(+), 214 deletions(-) rename KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/{ScriptCodeGenerator.cs => CFG2CSGenerator.cs} (99%) rename KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/{ScriptAssemblyCompiler.cs => CSCompiler.cs} (75%) rename KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/{CFGBuilderFromBlueprint.cs => BP2CFGConverter.cs} (96%) rename KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/{ScriptSerializer.cs => BlockScriptSerializer.cs} (99%) rename KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/{ScriptGenerator.cs => CFG2BSConverter.cs} (99%) rename KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/{ScriptFormatter.cs => BS2CFGConverter.cs} (99%) rename KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/{NodeBuilder.cs => CFG2BPConverter.cs} (98%) diff --git a/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs b/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs index 91f4f950..db49e483 100644 --- a/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs +++ b/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs @@ -460,8 +460,8 @@ private static void RunAssemblyCompilationTest( parseResult.Script.HelperFunctions = GetExecutionHelpers(); - // Test 1: Direct ScriptAssemblyCompiler test - var compiler = new ScriptAssemblyCompiler(); + // Test 1: Direct CSCompiler test + var compiler = new CSCompiler(); Console.WriteLine("[Test L] Compiling..."); var compiled = compiler.CompileScript(parseResult.Script); Console.WriteLine($"[Test L] Assembly compilation: {(compiled != null ? "SUCCESS" : "FAILED (null)")}"); @@ -604,7 +604,7 @@ private static string GetPreExpandedScript() => @"#ConstBlock Print(""示例工作流结束"");"; // ────────────────────────────────────────────── - // Test B: Raw nested format (ScriptFormatter must expand) + // Test B: Raw nested format (BS2CFGConverter must expand) // ────────────────────────────────────────────── private static string GetRawNestedScript() => @"#ConstBlock int guessNum = 5; @@ -1052,7 +1052,7 @@ private static void RunCrossDevicePluginCallTest( parseResult.Script.HelperFunctions = GetExecutionHelpers(); // Compile to assembly - var compiler = new ScriptAssemblyCompiler(); + var compiler = new CSCompiler(); var compiled = compiler.CompileScript(parseResult.Script); Console.WriteLine($" Assembly compilation: {(compiled != null ? "SUCCESS" : "FAILED (null)")}"); @@ -1224,7 +1224,7 @@ private static void RunJsonGetFieldTest( } else { - var compiler = new ScriptAssemblyCompiler(); + var compiler = new CSCompiler(); var compiled = compiler.CompileScript(parseResult.Script); Console.WriteLine($" Assembly compilation: {(compiled != null ? "SUCCESS" : "FAILED (null)")}"); @@ -1492,7 +1492,7 @@ private static void RunBuiltinAssemblyTest( } else { - var compiler = new ScriptAssemblyCompiler(); + var compiler = new CSCompiler(); ICompiledBlockScript? compiled = null; try { @@ -1738,7 +1738,7 @@ private static void RunUninitializedVarTest( { parseResult.Script.HelperFunctions = GetExecutionHelpers(); - var compiler = new ScriptAssemblyCompiler(); + var compiler = new CSCompiler(); var compiled = compiler.CompileScript(parseResult.Script); Console.WriteLine($" Compilation: {(compiled != null ? "SUCCESS" : "FAILED")}"); diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutor.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutor.cs index 360c0184..f1809160 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutor.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutor.cs @@ -23,7 +23,7 @@ public class BlockScriptExecutor : IBlockScriptExecutor private BlockScriptExecutionGlobals? _globals; private IPluginManager? _pluginManager; private string? _workflowId; - private readonly ScriptAssemblyCompiler _assemblyCompiler = new(); + private readonly CSCompiler _assemblyCompiler = new(); private IBlueprintDebugController? _debugger; /// @@ -71,7 +71,7 @@ public void SetWorkflowId(string? workflowId) public void SetDebugger(IBlueprintDebugController? debugger) { _debugger = debugger; - ScriptCodeGenerator.IsDebugMode = debugger != null; + CFG2CSGenerator.IsDebugMode = debugger != null; } /// @@ -114,7 +114,7 @@ public async Task ExecuteAsync( try { - ScriptCodeGenerator.IsDebugMode = _debugger != null; + CFG2CSGenerator.IsDebugMode = _debugger != null; // Full-script assembly compilation var compiled = _assemblyCompiler.CompileScript(script, _workflowId); @@ -188,7 +188,7 @@ internal async Task ExecuteFromCFGAsync( try { - ScriptCodeGenerator.IsDebugMode = _debugger != null; + CFG2CSGenerator.IsDebugMode = _debugger != null; var compiled = _assemblyCompiler.CompileFromCFG(cfg, script, _workflowId); if (compiled == null) diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PluginCallWithTargetFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PluginCallWithTargetFunction.cs index 72f4f5cc..e7cf99f3 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PluginCallWithTargetFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PluginCallWithTargetFunction.cs @@ -67,7 +67,7 @@ public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) if (node is CallNode call) { // Use stmt.Arguments directly — it contains the properly expanded argument strings - // from the ScriptFormatter's ExpandArguments step. First 3 args are + // from the BS2CFGConverter's ExpandArguments step. First 3 args are // plugin name, method name, target device; remaining are extra args. var allArgs = stmt.Arguments; if (allArgs != null && allArgs.Count >= 3) diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptCodeGenerator.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/CFG2CSGenerator.cs similarity index 99% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptCodeGenerator.cs rename to KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/CFG2CSGenerator.cs index 3238ef7f..64ccbcc9 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptCodeGenerator.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/CFG2CSGenerator.cs @@ -24,10 +24,10 @@ namespace KitX.Core.Workflow.BlockScripting; /// The generated class implements with a Run method /// that executes all blocks via a while(true) + switch(G.NextBlock) dispatcher. /// -internal static class ScriptCodeGenerator +internal static class CFG2CSGenerator { private static readonly BuiltinFunctionRegistry FunctionRegistry = - BuiltinFunctionRegistry.Discover(typeof(ScriptCodeGenerator).Assembly); + BuiltinFunctionRegistry.Discover(typeof(CFG2CSGenerator).Assembly); public static bool IsDebugMode { get; set; } @@ -107,7 +107,7 @@ internal static Dictionary InferPubVarTypes( } } - Log.Debug("[ScriptCodeGenerator] Type inference: {Count} PubVars typed: {Types}", + Log.Debug("[CFG2CSGenerator] Type inference: {Count} PubVars typed: {Types}", pubVarTypes.Count, string.Join(", ", pubVarTypes.Select(kv => $"{kv.Key}={kv.Value}"))); @@ -272,7 +272,7 @@ internal static List ParseHelperFunctionBody(string? code) return block.Statements.ToList(); } - Log.Warning("[ScriptCodeGenerator] Failed to parse helper function body, using empty body"); + Log.Warning("[CFG2CSGenerator] Failed to parse helper function body, using empty body"); return new List { ReturnStatement(LiteralExpression(SyntaxKind.NullLiteralExpression)) }; } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptAssemblyCompiler.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/CSCompiler.cs similarity index 75% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptAssemblyCompiler.cs rename to KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/CSCompiler.cs index f037f6eb..a7dd18a1 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptAssemblyCompiler.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/CSCompiler.cs @@ -1,4 +1,4 @@ -using KitX.Core.Workflow.Blueprint.CFG; +using KitX.Core.Workflow.Blueprint.CFG; using System; using System.Collections.Generic; using KitX.Core.Contract.Workflow; @@ -13,30 +13,21 @@ namespace KitX.Core.Workflow.BlockScripting; /// implementing with a Run method. /// /// This class acts as a coordinator, delegating to specialized components: -/// for Roslyn syntax generation, +/// for Roslyn syntax generation, /// for compilation, and /// for disk caching. /// /// Compilation results are cached by script hash. Loaded assemblies use /// for unloadability. /// -internal class ScriptAssemblyCompiler +internal class CSCompiler { /// - /// Well-known identifiers that must be prefixed with G. in the compiled assembly. - /// - private static readonly HashSet GlobalsIdentifiers = new(StringComparer.Ordinal) - { - "Print", "Set", "Get", "Branch", "Loop", "ToLoopCond", - "Flip", "PluginCall", "PluginCallWithTarget", "Pause", "NextBlock" - }; - - /// - /// Auto-discovered builtin function registry used by the + /// Auto-discovered builtin function registry used by the /// to correctly classify and expand function calls during the formatting phase. /// private static readonly BuiltinFunctionRegistry FunctionRegistry = - BuiltinFunctionRegistry.Discover(typeof(ScriptAssemblyCompiler).Assembly); + BuiltinFunctionRegistry.Discover(typeof(CSCompiler).Assembly); /// /// Cached compiled script entries, keyed by computed script hash. @@ -51,11 +42,11 @@ internal class ScriptAssemblyCompiler /// /// Initializes a new compiler instance with its persistence manager. /// - public ScriptAssemblyCompiler() + public CSCompiler() { _persistence = new ScriptPersistenceManager( registerCacheEntry: (hash, entry) => _cache[hash] = entry, - getKitXVersion: () => typeof(ScriptAssemblyCompiler).Assembly.GetName().Version?.ToString() ?? "0.0.0.0", + getKitXVersion: () => typeof(CSCompiler).Assembly.GetName().Version?.ToString() ?? "0.0.0.0", tryGetCacheEntry: hash => _cache.TryGetValue(hash, out var entry) ? entry : null); } @@ -81,7 +72,7 @@ public ScriptAssemblyCompiler() // Step 1: Check in-memory cache if (_cache.TryGetValue(hash, out var entry) && entry.IsAlive) { - Log.Debug("[ScriptAssemblyCompiler] Memory cache hit for hash '{Hash}'", hash); + Log.Debug("[CSCompiler] Memory cache hit for hash '{Hash}'", hash); return entry.Instance; } @@ -91,7 +82,7 @@ public ScriptAssemblyCompiler() var diskInstance = _persistence.TryLoadFromDisk(workflowId, hash); if (diskInstance != null) { - Log.Debug("[ScriptAssemblyCompiler] Disk cache hit for hash '{Hash}' (workflow: {WfId})", + Log.Debug("[CSCompiler] Disk cache hit for hash '{Hash}' (workflow: {WfId})", hash, workflowId); return diskInstance; } @@ -104,7 +95,7 @@ public ScriptAssemblyCompiler() var (formattedScript, pubVarTypes) = FormatAndInferTypes(script); // Phase 2: Generate CompilationUnitSyntax - var compilationUnit = ScriptCodeGenerator.GenerateCompilationUnit( + var compilationUnit = CFG2CSGenerator.GenerateCompilationUnit( script, formattedScript, pubVarTypes, hash); // Phase 3: Compile via CSharpCompilation @@ -119,7 +110,7 @@ public ScriptAssemblyCompiler() var scriptType = loadedAssembly.GetType(typeName); if (scriptType == null) { - Log.Warning("[ScriptAssemblyCompiler] Compiled type not found in assembly"); + Log.Warning("[CSCompiler] Compiled type not found in assembly"); alc.Unload(); return null; } @@ -133,12 +124,12 @@ public ScriptAssemblyCompiler() _persistence.SaveToDisk(workflowId, hash, assembly, typeName); } - Log.Debug("[ScriptAssemblyCompiler] Successfully compiled and cached script hash '{Hash}'", hash); + Log.Debug("[CSCompiler] Successfully compiled and cached script hash '{Hash}'", hash); return instance; } catch (Exception ex) { - Log.Warning(ex, "[ScriptAssemblyCompiler] Compilation failed, returning null for fallback"); + Log.Warning(ex, "[CSCompiler] Compilation failed, returning null for fallback"); return null; } } @@ -152,11 +143,11 @@ public ScriptAssemblyCompiler() ControlFlowGraph cfg, BlockScript script, string? workflowId) { var baseHash = ScriptCompilationBackend.ComputeScriptHash(script); - var hash = ScriptCodeGenerator.IsDebugMode ? $"debug_{baseHash}" : baseHash; + var hash = CFG2CSGenerator.IsDebugMode ? $"debug_{baseHash}" : baseHash; if (_cache.TryGetValue(hash, out var entry) && entry.IsAlive) { - Log.Debug("[ScriptAssemblyCompiler] Memory cache hit for hash '{Hash}'", hash); + Log.Debug("[CSCompiler] Memory cache hit for hash '{Hash}'", hash); return entry.Instance; } @@ -165,14 +156,14 @@ public ScriptAssemblyCompiler() var diskInstance = _persistence.TryLoadFromDisk(workflowId, hash); if (diskInstance != null) { - Log.Debug("[ScriptAssemblyCompiler] Disk cache hit for hash '{Hash}'", hash); + Log.Debug("[CSCompiler] Disk cache hit for hash '{Hash}'", hash); return diskInstance; } } try { - Log.Debug("[ScriptAssemblyCompiler] Compiling from pre-built CFG: {BlockCount} blocks", cfg.Blocks.Count); + Log.Debug("[CSCompiler] Compiling from pre-built CFG: {BlockCount} blocks", cfg.Blocks.Count); var context = new PipelineContext { Script = script }; if (script.PubVarBlock != null) @@ -183,9 +174,9 @@ public ScriptAssemblyCompiler() context.PubVarNames.Add(variable.Name); } } - var pubVarTypes = ScriptCodeGenerator.InferPubVarTypes(cfg, script.HelperFunctions, context); + var pubVarTypes = CFG2CSGenerator.InferPubVarTypes(cfg, script.HelperFunctions, context); - var compilationUnit = ScriptCodeGenerator.GenerateCompilationUnit(script, cfg, pubVarTypes, hash); + var compilationUnit = CFG2CSGenerator.GenerateCompilationUnit(script, cfg, pubVarTypes, hash); var assembly = ScriptCompilationBackend.CompileToAssembly(compilationUnit, hash); if (assembly == null) return null; @@ -201,12 +192,12 @@ public ScriptAssemblyCompiler() if (workflowId != null) _persistence.SaveToDisk(workflowId, hash, assembly, typeName); - Log.Debug("[ScriptAssemblyCompiler] CompileFromCFG success for hash '{Hash}'", hash); + Log.Debug("[CSCompiler] CompileFromCFG success for hash '{Hash}'", hash); return instance; } catch (Exception ex) { - Log.Warning(ex, "[ScriptAssemblyCompiler] CompileFromCFG failed"); + Log.Warning(ex, "[CSCompiler] CompileFromCFG failed"); return null; } } @@ -231,7 +222,7 @@ public void ClearCache() // ────────────────────────────────────────────── /// - /// Formats the script using and infers PubVar types. + /// Formats the script using and infers PubVar types. /// private (ControlFlowGraph formatted, Dictionary pubVarTypes) FormatAndInferTypes( BlockScript script) @@ -247,22 +238,21 @@ public void ClearCache() } } - var formatter = new ScriptFormatter(script.HelperFunctions ?? [], FunctionRegistry); - var formattedScript = formatter.Format(script, context); + var formattedScript = CFGPipeline.BS2CFG(script, script.HelperFunctions ?? [], FunctionRegistry, context); - Log.Debug("[ScriptAssemblyCompiler] Formatted script: {BlockCount} blocks, MainBlock={Main}", + Log.Debug("[CSCompiler] Formatted script: {BlockCount} blocks, MainBlock={Main}", formattedScript.Blocks.Count, formattedScript.MainBlockName); foreach (var block in formattedScript.Blocks) { - Log.Debug("[ScriptAssemblyCompiler] Block '{Name}' → NextBlock={Next}, Statements={Count}", + Log.Debug("[CSCompiler] Block '{Name}' → NextBlock={Next}, Statements={Count}", block.Name, block.NextBlockName, block.Statements.Count); foreach (var stmt in block.Statements) - Log.Debug("[ScriptAssemblyCompiler] Kind={Kind} PubVar={PubVar} Fn={Fn} Args=[{Args}] CondPubVar={Cond} SetVar={Set} GetVar={Get}", + Log.Debug("[CSCompiler] Kind={Kind} PubVar={PubVar} Fn={Fn} Args=[{Args}] CondPubVar={Cond} SetVar={Set} GetVar={Get}", stmt.Kind, stmt.PubVarTarget, stmt.FunctionName, string.Join(", ", stmt.Arguments), stmt.ConditionPubVar, stmt.SetVarName, stmt.GetVarName); } - var pubVarTypes = ScriptCodeGenerator.InferPubVarTypes(formattedScript, script.HelperFunctions, context); + var pubVarTypes = CFG2CSGenerator.InferPubVarTypes(formattedScript, script.HelperFunctions, context); return (formattedScript, pubVarTypes); } } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/IBuiltinFunctionDefinition.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/IBuiltinFunctionDefinition.cs index 2713fd79..5f3bfaa0 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/IBuiltinFunctionDefinition.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/IBuiltinFunctionDefinition.cs @@ -23,11 +23,11 @@ public interface IBuiltinFunctionDefinition // ─── 分类 ─────────────────────────────────────── - /// 是否为控制流函数(如 Branch/Loop)。影响 ScriptFormatter 的展开策略和 NodeBuilder 的跨块边解析。 + /// 是否为控制流函数(如 Branch/Loop)。影响 BS2CFGConverter 的展开策略和 CFG2BPConverter 的跨块边解析。 bool IsFlowControl { get; } /// - /// ScriptFormatter 是否应将其保持内联(不展开嵌套调用)。 + /// BS2CFGConverter 是否应将其保持内联(不展开嵌套调用)。 /// 如 Set、Print、Pause 等直接执行副作用的函数应标记为 true。 /// bool IsNonExtractable { get; } @@ -35,8 +35,8 @@ public interface IBuiltinFunctionDefinition // ─── 语句类型映射 ───────────────────────────────── /// - /// 对应的 CFGStatementKind。用于 ScriptFormatter 确定语句类型, - /// 以及 NodeBuilder 选择节点创建策略。 + /// 对应的 CFGStatementKind。用于 BS2CFGConverter 确定语句类型, + /// 以及 CFG2BPConverter 选择节点创建策略。 /// CFGStatementKind StatementKind { get; } @@ -44,7 +44,7 @@ public interface IBuiltinFunctionDefinition /// /// 从调用表达式中提取语句特定的字段(如 Set 的变量名、Get 的变量名和 PubVar)。 - /// ScriptFormatter.FormatInvocation 在处理已注册函数时调用此方法获取 Kind 之外的特殊字段。 + /// BS2CFGConverter.FormatInvocation 在处理已注册函数时调用此方法获取 Kind 之外的特殊字段。 /// 默认实现不提取任何特殊字段。 /// /// 原始 Roslyn 调用表达式 @@ -121,7 +121,7 @@ List FormatInvocation( /// /// 是否终止当前块(如 Branch/Loop/Flip 执行后不应继续顺序执行)。 - /// 默认 false。设为 true 会使 NodeBuilder 标记 blockEndsWithFlowCtrl。 + /// 默认 false。设为 true 会使 CFG2BPConverter 标记 blockEndsWithFlowCtrl。 /// bool IsBlockTerminator => false; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlockScriptToBlueprintConverter.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlockScriptToBlueprintConverter.cs index 9fda311a..297b763c 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlockScriptToBlueprintConverter.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlockScriptToBlueprintConverter.cs @@ -3,6 +3,7 @@ using System.Linq; using KitX.Core.Contract.Workflow; using KitX.Core.Workflow.BlockScripting; +using KitX.Core.Workflow.Blueprint.CFG; using KitX.Core.Workflow.Blueprint.Pipeline; using Serilog; @@ -71,15 +72,14 @@ public Contract.Workflow.Blueprint Convert(BlockScript script) context.ConstNodes.Count, context.PubVarNames.Count); // ── Phase 2: Script formatting (expand nested calls + loop condition duplication) ── - var formatter = new ScriptFormatter(helpers, _functionRegistry); - context.FormattedScript = formatter.Format(script, context); + var cfg = CFGPipeline.BS2CFG(script, helpers, _functionRegistry, context); + context.FormattedScript = cfg; Log.Debug("[Converter] Phase 2: {BlockCount} blocks, {StmtCount} statements", - context.FormattedScript.Blocks.Count, - context.FormattedScript.Blocks.Sum(b => b.Statements.Count)); + cfg.Blocks.Count, + cfg.Blocks.Sum(b => b.Statements.Count)); - // ── Phase 3: Node creation + exec edges + PubVar reuse ── - var nodeBuilder = new NodeBuilder(_nodeRegistry, helpers, _functionRegistry); - nodeBuilder.Build(context.FormattedScript, context); + // ── Phase 3: CFG → BP via pipeline ── + CFGPipeline.CFG2BP(context.FormattedScript, context, _nodeRegistry, helpers, _functionRegistry); Log.Debug("[Converter] Phase 3: {NodeCount} nodes, {ExecEdgeCount} exec edges", context.AllNodes.Count, context.ExecEdges.Count); diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintToBlockScriptConverter.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintToBlockScriptConverter.cs index f2e1d308..160c76ad 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintToBlockScriptConverter.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintToBlockScriptConverter.cs @@ -8,68 +8,59 @@ namespace KitX.Core.Workflow.Blueprint; /// /// Converts Blueprint back to a fully-expanded BlockScript source code. -/// Thin orchestrator that delegates to CFG pipeline phases: -/// CFGBuilderFromBlueprint → CFGConditionDuplicator → ScriptGenerator → ScriptSerializer +/// Thin orchestrator that delegates to CFGPipeline phases. /// public class BlueprintToBlockScriptConverter : IBlueprintToBlockScriptConverter { private readonly NodeExportHelper _exportHelper = new(); + private readonly Dictionary _strategyMap; + private readonly Dictionary _builtinMap; // CFG pipeline components - private readonly CFGBuilderFromBlueprint _cfgBuilder; + private readonly BP2CFGConverter _cfgBuilder; private readonly CFGConditionDuplicator _cfgConditionDuplicator = new(); - private readonly ScriptGenerator _scriptGenerator = new(); - private readonly ScriptSerializer _scriptSerializer = new(); + private readonly CFG2BSConverter _cfg2bs = new(); + private readonly BlockScriptSerializer _serializer = new(); public BlueprintToBlockScriptConverter(IEnumerable strategies) { - var strategyMap = new Dictionary(); - var builtinMap = new Dictionary(); + _strategyMap = new Dictionary(); + _builtinMap = new Dictionary(); foreach (var s in strategies) { if (s is BuiltinFunctionExportStrategyAdapter adapter) { - builtinMap[adapter.FunctionName] = s; - // Standard functions also register by their legacy NodeType for reverse conversion + _builtinMap[adapter.FunctionName] = s; if (adapter.LegacyNodeType != null) - strategyMap[adapter.LegacyNodeType.Value] = s; + _strategyMap[adapter.LegacyNodeType.Value] = s; } else { - strategyMap[s.NodeType] = s; + _strategyMap[s.NodeType] = s; } } - _cfgBuilder = new CFGBuilderFromBlueprint(strategyMap, builtinMap, _exportHelper); + _cfgBuilder = new BP2CFGConverter(_strategyMap, _builtinMap, _exportHelper); } - /// Last CFG built, for diagnostics internal ControlFlowGraph? LastCFG { get; private set; } - // ────────────────────────────────────────────── - // Public API - // ────────────────────────────────────────────── - - /// public Contract.Workflow.Blueprint Blueprint { get; private set; } = null!; - /// public string Convert(Contract.Workflow.Blueprint blueprint) => ConvertToBlockScript(blueprint).SourceCode; - /// public BlockScript ConvertToBlockScript(Contract.Workflow.Blueprint blueprint) { Blueprint = blueprint; - // Set up context for NodeExportHelper and CFG builder (needed for statement generation) var ctx = new ConversionContext { Blueprint = blueprint, Script = new BlockScript() }; _exportHelper.SetContext(blueprint, ctx); _cfgBuilder.SetContext(blueprint, ctx); - // Phase 1: Build CFG from Blueprint (unified algorithm) - var cfg = _cfgBuilder.Build(blueprint); + // Phase 1: BP → CFG via pipeline + var cfg = CFGPipeline.BP2CFG(blueprint, _strategyMap, _builtinMap, _exportHelper, prebuiltBuilder: _cfgBuilder); LastCFG = cfg; Log.Debug("[BlueprintToScript] CFG: {BlockCount} blocks, {EdgeCount} edges", @@ -78,16 +69,14 @@ public BlockScript ConvertToBlockScript(Contract.Workflow.Blueprint blueprint) // Phase 2: Duplicate loop conditions _cfgConditionDuplicator.Duplicate(cfg); - // Phase 3: Generate BlockScript from CFG - var script = _scriptGenerator.Generate(cfg); + // Phase 3: CFG → BS via pipeline + var script = CFGPipeline.CFG2BS(cfg); // Phase 4: Serialize to source code - script.SourceCode = _scriptSerializer.Serialize(script); + script.SourceCode = _serializer.Serialize(script); - // Transfer helper functions from Blueprint to BlockScript script.HelperFunctions = blueprint.HelperFunctions ?? []; - // Preserve debug mapping for the execution pipeline if (cfg.DebugContext != null) script.DebugNodeMapping = new Dictionary(cfg.DebugContext.StatementToNodeId); @@ -97,4 +86,4 @@ public BlockScript ConvertToBlockScript(Contract.Workflow.Blueprint blueprint) return script; } -} \ No newline at end of file +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGBuilderFromBlueprint.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/BP2CFGConverter.cs similarity index 96% rename from KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGBuilderFromBlueprint.cs rename to KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/BP2CFGConverter.cs index ad536ce5..5ddbfb57 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGBuilderFromBlueprint.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/BP2CFGConverter.cs @@ -20,13 +20,13 @@ namespace KitX.Core.Workflow.Blueprint.CFG; /// this single algorithm produces a CFG. When BlockScopes are available, they guide /// block membership; when absent, blocks are derived from topology alone. /// -internal class CFGBuilderFromBlueprint +internal class BP2CFGConverter { private readonly Dictionary _strategies; private readonly Dictionary _builtinFunctionStrategies; private readonly NodeExportHelper _exportHelper; - public CFGBuilderFromBlueprint( + public BP2CFGConverter( Dictionary strategies, Dictionary builtinFunctionStrategies, NodeExportHelper exportHelper) @@ -126,7 +126,7 @@ public ControlFlowGraph Build(Contract.Workflow.Blueprint blueprint) }); } - Log.Debug("[CFGBuilderFromBlueprint] Built CFG: {BlockCount} blocks, {EdgeCount} edges", + Log.Debug("[BP2CFGConverter] Built CFG: {BlockCount} blocks, {EdgeCount} edges", cfg.Blocks.Count, cfg.Blocks.Sum(b => b.Successors.Count)); // ── Step 9: Populate debug node mapping ── @@ -863,7 +863,7 @@ private static void SetParentLoopReferences(ControlFlowGraph cfg) if (!_strategies.TryGetValue(node.NodeType, out var strategy)) { - Log.Warning("[CFGBuilderFromBlueprint] Unhandled node type: {NodeType}", node.NodeType); + Log.Warning("[BP2CFGConverter] Unhandled node type: {NodeType}", node.NodeType); return null; } @@ -954,39 +954,42 @@ private CFGStatement ConvertBlockStatementToCfgStatement(BlockStatement blockStm else { cfgStmt.Kind = CFGStatementKind.Expression; + // Fallback: parse FunctionName from SourceCode for nodes without strategy (CallHelper, Call, etc.) + var fallbackParsed = ExprUtils.ParseStatement(expr.SourceCode); + if (fallbackParsed?.rightExpr is InvocationExpressionSyntax fallbackInvoke) + cfgStmt.FunctionName = ExprUtils.GetMethodName(fallbackInvoke); } - // Arguments & PubVarTarget from SourceCode (generated from node data in same pass) - var parsed = ExprUtils.ParseStatement(expr.SourceCode); - if (parsed?.rightExpr is InvocationExpressionSyntax invoke) + // Arguments from node input pins (no text parsing) + cfgStmt.Arguments = new List(); + foreach (var pin in node.InputPins) { - cfgStmt.FunctionName ??= ExprUtils.GetMethodName(invoke); - cfgStmt.Arguments = invoke.ArgumentList.Arguments - .Select(a => a.Expression.ToString()).ToList(); - if (parsed.Value.assignedVar != null) - cfgStmt.PubVarTarget = parsed.Value.assignedVar; - - // Extract GetVarName / SetVarName from string literal first argument - if (cfgStmt.Arguments.Count > 0) + if (pin.Name != "Exec") + cfgStmt.Arguments.Add(_exportHelper.GetInputValue(node, pin.Name)); + } + + // SetVarName / GetVarName from node properties + if (node is SetNode sn && !string.IsNullOrEmpty(sn.VarName)) + cfgStmt.SetVarName = sn.VarName; + else if (node is GetNode gn && !string.IsNullOrEmpty(gn.VarName)) + cfgStmt.GetVarName = gn.VarName; + else if (node is BuiltinFunctionNode bfnProps) + { + bfnProps.Properties.TryGetValue("VarName", out var vn); + if (!string.IsNullOrEmpty(vn)) { - var firstArg = invoke.ArgumentList.Arguments[0].Expression; - var strVal = ExprUtils.GetStringLiteralValue(firstArg); - if (!string.IsNullOrEmpty(strVal)) - { - if (cfgStmt.FunctionName == "Set") - { - cfgStmt.SetVarName = strVal; - // Remove var name from Arguments (matching FormatInvocation behaviour) - cfgStmt.Arguments.RemoveAt(0); - } - else if (cfgStmt.FunctionName == "Get") - { - cfgStmt.GetVarName = strVal; - cfgStmt.Arguments.RemoveAt(0); - } - } + if (bfnProps.FunctionName == "Set") cfgStmt.SetVarName = vn; + else if (bfnProps.FunctionName == "Get") cfgStmt.GetVarName = vn; } } + + // PubVarTarget from consumed output lookup + if (_currentCtx != null) + { + var outputPin = node.OutputPins.FirstOrDefault(p => p.Type != PinType.Execution); + if (outputPin != null) + cfgStmt.PubVarTarget = NodeExportHelper.FindOutputPubVar(node, outputPin.Name, _currentCtx); + } break; default: @@ -1194,4 +1197,4 @@ private static bool IsNodeType(BlueprintNode node, BlueprintNodeType type) => private static bool IsBranchNode(BlueprintNode node) => IsNodeType(node, BlueprintNodeType.Branch); private static bool IsLoopNode(BlueprintNode node) => IsNodeType(node, BlueprintNodeType.Loop); -} \ No newline at end of file +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/ScriptSerializer.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/BlockScriptSerializer.cs similarity index 99% rename from KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/ScriptSerializer.cs rename to KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/BlockScriptSerializer.cs index 03383c1e..085a978d 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/ScriptSerializer.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/BlockScriptSerializer.cs @@ -15,7 +15,7 @@ namespace KitX.Core.Workflow.Blueprint.CFG; /// using /// , not from ad-hoc patches. /// -internal class ScriptSerializer +internal class BlockScriptSerializer { /// /// Serializes a BlockScript to source code. @@ -116,4 +116,4 @@ or FlowControlType.ToLoopCond } return false; } -} \ No newline at end of file +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/ScriptGenerator.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFG2BSConverter.cs similarity index 99% rename from KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/ScriptGenerator.cs rename to KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFG2BSConverter.cs index 96cb473b..ec83d523 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/ScriptGenerator.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFG2BSConverter.cs @@ -14,7 +14,7 @@ namespace KitX.Core.Workflow.Blueprint.CFG; /// Replaces the former ad-hoc logic with a single, principled transformation from CFG to BlockScript. /// with a single, principled transformation from CFG to BlockScript. /// -internal class ScriptGenerator +internal class CFG2BSConverter { /// /// Generates a BlockScript from a ControlFlowGraph. @@ -202,4 +202,4 @@ private static string ExtractExpression(string sourceCode) return trimmed[..^1]; return trimmed; } -} \ No newline at end of file +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGBuilderFromScript.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGBuilderFromScript.cs index cfb769f0..2b7671f2 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGBuilderFromScript.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGBuilderFromScript.cs @@ -13,7 +13,7 @@ namespace KitX.Core.Workflow.Blueprint.CFG; /// This is the canonical transformation for the BS→BP pipeline, replacing the /// ad-hoc intermediate. /// -/// Strategy: uses the existing for expression +/// Strategy: uses the existing for expression /// expansion, then converts the to a /// with typed edges. /// @@ -30,13 +30,13 @@ public CFGBuilderFromScript(List helperFunctions, BuiltinFunctio /// /// Builds a ControlFlowGraph from a parsed BlockScript. - /// The ScriptFormatter handles expression expansion; this method adds + /// The BS2CFGConverter handles expression expansion; this method adds /// CFG structure (blocks, edges, block types) and type classification. /// public ControlFlowGraph Build(BlockScript script, Pipeline.PipelineContext context) { - // Phase 1: Use ScriptFormatter for expression expansion - var formatter = new Pipeline.ScriptFormatter(_helperFunctions, _functionRegistry); + // Phase 1: Use BS2CFGConverter for expression expansion + var formatter = new Pipeline.BS2CFGConverter(_helperFunctions, _functionRegistry); var formatted = formatter.Format(script, context); // Phase 2: Convert ControlFlowGraph to ControlFlowGraph @@ -276,4 +276,4 @@ private static void SetParentLoopReferences(ControlFlowGraph cfg, BlockScript sc } } } -} \ No newline at end of file +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGPipeline.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGPipeline.cs index 8d8e1172..fe35308d 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGPipeline.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGPipeline.cs @@ -32,53 +32,58 @@ internal static class CFGPipeline { /// /// BS → CFG: parse source code, expand syntax sugar, allocate IDs. - /// Equivalent to ScriptFormatter.Format(). + /// Equivalent to BS2CFGConverter.Format(). /// internal static ControlFlowGraph BS2CFG( BlockScript script, List helpers, - BuiltinFunctionRegistry functionRegistry) + BuiltinFunctionRegistry? functionRegistry, + PipelineContext? context = null) { - var context = new PipelineContext { Script = script }; - var formatter = new ScriptFormatter(helpers, functionRegistry); - var cfg = formatter.Format(script, context); + var ctx = context ?? new PipelineContext { Script = script }; + var formatter = new BS2CFGConverter(helpers, functionRegistry); + var cfg = formatter.Format(script, ctx); cfg.DebugContext = new BlueprintDebugContext(); return cfg; } /// /// BP → CFG: build from Blueprint nodes, StatementId = node.Id. - /// Equivalent to CFGBuilderFromBlueprint.Build(). + /// Equivalent to BP2CFGConverter.Build(). /// internal static ControlFlowGraph BP2CFG( Contract.Workflow.Blueprint blueprint, Dictionary strategyMap, Dictionary builtinMap, - NodeExportHelper exportHelper) + NodeExportHelper exportHelper, + BP2CFGConverter? prebuiltBuilder = null) { - var builder = new CFGBuilderFromBlueprint(strategyMap, builtinMap, exportHelper); - builder.SetContext(blueprint, new ConversionContext + var builder = prebuiltBuilder ?? new BP2CFGConverter(strategyMap, builtinMap, exportHelper); + if (prebuiltBuilder == null) { - Blueprint = blueprint, - Script = new BlockScript() - }); + builder.SetContext(blueprint, new ConversionContext + { + Blueprint = blueprint, + Script = new BlockScript() + }); + } return builder.Build(blueprint); } /// /// CFG → BS: serialize to BlockScript, preserving StatementId. - /// Equivalent to ScriptGenerator.Generate(). + /// Equivalent to CFG2BSConverter.Generate(). /// internal static BlockScript CFG2BS(ControlFlowGraph cfg) { - var generator = new ScriptGenerator(); + var generator = new CFG2BSConverter(); var script = generator.Generate(cfg); return script; } /// /// CFG → BP: build visual Blueprint nodes. - /// Equivalent to NodeBuilder.Build(). + /// Equivalent to CFG2BPConverter.Build(). /// internal static void CFG2BP( ControlFlowGraph cfg, @@ -87,7 +92,7 @@ internal static void CFG2BP( List helpers, BuiltinFunctionRegistry? functionRegistry = null) { - var nodeBuilder = new NodeBuilder(registry, helpers, functionRegistry); - nodeBuilder.Build(cfg, context); + var builder = new CFG2BPConverter(registry, helpers, functionRegistry); + builder.Build(cfg, context); } } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ScriptFormatter.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/BS2CFGConverter.cs similarity index 99% rename from KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ScriptFormatter.cs rename to KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/BS2CFGConverter.cs index 6d4d9e9b..6fbc071d 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ScriptFormatter.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/BS2CFGConverter.cs @@ -17,12 +17,12 @@ namespace KitX.Core.Workflow.Blueprint.Pipeline; /// where all nested function calls have been expanded into PubVar assignments. /// Also duplicates Loop condition evaluations before ToLoopCond statements. /// -public class ScriptFormatter +public class BS2CFGConverter { private readonly List _helperFunctions; private readonly BuiltinFunctionRegistry? _functionRegistry; - public ScriptFormatter(List helperFunctions, BuiltinFunctionRegistry? functionRegistry = null) + public BS2CFGConverter(List helperFunctions, BuiltinFunctionRegistry? functionRegistry = null) { _helperFunctions = helperFunctions; _functionRegistry = functionRegistry; @@ -58,7 +58,7 @@ public ControlFlowGraph Format(BlockScript script, PipelineContext context) } } - Log.Debug("[ScriptFormatter] Done: {BlockCount} blocks, {StmtCount} statements", + Log.Debug("[BS2CFGConverter] Done: {BlockCount} blocks, {StmtCount} statements", result.Blocks.Count, result.Blocks.Sum(b => b.Statements.Count)); return result; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/NodeBuilder.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/CFG2BPConverter.cs similarity index 98% rename from KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/NodeBuilder.cs rename to KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/CFG2BPConverter.cs index 1829993b..f7bbe1d9 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/NodeBuilder.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/CFG2BPConverter.cs @@ -15,7 +15,7 @@ namespace KitX.Core.Workflow.Blueprint.Pipeline; /// Phase 3: Creates all Blueprint nodes and exec flow edges from ControlFlowGraph. /// Implements PubVar reuse detection during node creation (§6.5). /// -public class NodeBuilder +public class CFG2BPConverter { private readonly INodeRegistry _registry; private readonly List _helpers; @@ -26,7 +26,7 @@ public class NodeBuilder private readonly List<(string stmtId, string returnToBlock, string blockName, string? prevStmtId)> _loopBodyEndDefs = []; private readonly Dictionary _blockLastStmtId = new(); - public NodeBuilder(INodeRegistry registry, List helpers, BuiltinFunctionRegistry? functionRegistry = null) + public CFG2BPConverter(INodeRegistry registry, List helpers, BuiltinFunctionRegistry? functionRegistry = null) { _registry = registry; _helpers = helpers; @@ -58,7 +58,7 @@ public void Build(ControlFlowGraph script, PipelineContext context) script.DebugContext.StatementToNodeId[kvp.Key] = kvp.Value.Id; } - Log.Debug("[NodeBuilder] Done: {NodeCount} nodes, {ExecEdgeCount} exec edges", + Log.Debug("[CFG2BPConverter] Done: {NodeCount} nodes, {ExecEdgeCount} exec edges", context.AllNodes.Count, context.ExecEdges.Count); } @@ -227,7 +227,7 @@ private bool IsRegistryFlowControlTerminator(CFGStatement stmt) prevNode = existing.SourceNode; prevStmtId = stmt.StatementId; - Log.Debug("[NodeBuilder] Reused node: {Key}", stmt.Fingerprint ?? stmt.PubVarTarget); + Log.Debug("[CFG2BPConverter] Reused node: {Key}", stmt.Fingerprint ?? stmt.PubVarTarget); return existing.SourceNode; } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ExprUtils.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ExprUtils.cs index 3640738c..d1bc51aa 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ExprUtils.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ExprUtils.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Text.RegularExpressions; using System.Threading; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; @@ -85,17 +84,6 @@ public static string GetFullMethodName(InvocationExpressionSyntax invoke) return string.Empty; } - /// Checks if a token is a simple variable reference. - public static bool IsVariableReference(string token) - { - if (string.IsNullOrWhiteSpace(token)) return false; - if (bool.TryParse(token, out _)) return false; - if (int.TryParse(token, out _) || double.TryParse(token, out _)) return false; - if (token.StartsWith("\"")) return false; - if (token.Contains("(")) return false; - return Regex.IsMatch(token, @"^[a-zA-Z_]\w*$"); - } - /// /// Generates a PubVar name from a linear counter, cycling from vaaa0001 to vzzz9999. /// Format: 'v' + 3 lowercase letters + 4 digits. Total capacity: 26^3 * 10000 = 175,760,000. @@ -139,49 +127,10 @@ public static string ComputeFingerprint(string funcName, List args) return null; } - /// Gets the integer value of a literal, or null. - public static int? GetIntLiteralValue(ExpressionSyntax expr) - { - if (expr is LiteralExpressionSyntax lit && lit.Token.IsKind(SyntaxKind.NumericLiteralToken) - && lit.Token.Value is int val) - return val; - return null; - } - - /// Checks if an expression is a string literal. - public static bool IsStringLiteral(ExpressionSyntax expr) - => expr is LiteralExpressionSyntax lit && lit.Token.IsKind(SyntaxKind.StringLiteralToken); - - /// Checks if an expression is a numeric literal. - public static bool IsNumericLiteral(ExpressionSyntax expr) - => expr is LiteralExpressionSyntax lit && lit.Token.IsKind(SyntaxKind.NumericLiteralToken); - /// Gets the value of any literal expression (string, int, double, bool, null). public static object? GetLiteralValue(LiteralExpressionSyntax literal) => literal.Token.Value; - /// - /// Classifies a literal expression into a type tag used by the pipeline. - /// Uses Roslyn's SyntaxKind for reliable classification — no string-pattern guessing. - /// Returns: "string", "char", "number", "bool", or "unknown". - /// - public static string ClassifyLiteral(LiteralExpressionSyntax literal) - { - var kind = literal.Token.Kind(); - if (kind == SyntaxKind.StringLiteralToken) - return "string"; - if (kind == SyntaxKind.CharacterLiteralToken) - return "char"; - if (kind == SyntaxKind.NumericLiteralToken) - return "number"; - if (kind is SyntaxKind.TrueLiteralExpression or SyntaxKind.FalseLiteralExpression - || kind is SyntaxKind.TrueKeyword or SyntaxKind.FalseKeyword) - return "bool"; - if (kind == SyntaxKind.NullKeyword) - return "null"; - return "unknown"; - } - /// /// Determines whether a value string represents a C# character literal (e.g. '\0', 'a', '\n'). /// Uses Roslyn parsing for reliable detection — avoids string-pattern heuristics. diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineContext.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineContext.cs index 57dc828f..ec100163 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineContext.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineContext.cs @@ -32,7 +32,7 @@ public class PipelineContext public Dictionary BlockFirstNodes { get; set; } = new(); public List ExecEdges { get; set; } = new(); - // --- Block scope tracking (populated by NodeBuilder, consumed by Assembler) --- + // --- Block scope tracking (populated by CFG2BPConverter, consumed by Assembler) --- public Dictionary> BlockNodeIds { get; set; } = new(); public Dictionary BlockNextBlock { get; set; } = new(); public Dictionary BlockEndsWithFlowCtrl { get; set; } = new(); @@ -154,7 +154,7 @@ public class PendingDataEdge /// /// Generic deferred control flow edge for IBuiltinFunctionDefinition-based functions. /// Replaces per-function BranchDefs/LoopDefs with a unified structure. -/// Populated by OnNodeCreated, resolved by NodeBuilder.ResolveCrossBlockEdges. +/// Populated by OnNodeCreated, resolved by CFG2BPConverter.ResolveCrossBlockEdges. /// public struct DeferredControlFlowEdge { diff --git a/KitX Clients/KitX Dashboard b/KitX Clients/KitX Dashboard index 0e03058f..2c4135a5 160000 --- a/KitX Clients/KitX Dashboard +++ b/KitX Clients/KitX Dashboard @@ -1 +1 @@ -Subproject commit 0e03058f7e22bbcc9fc693e94dca8b1b56b64423 +Subproject commit 2c4135a52cef9f53c41b89fe49d3282d274584fd From 5b048648625ed6c6a4c87d88995c2f9b016e8b74 Mon Sep 17 00:00:00 2001 From: StarInk Date: Mon, 18 May 2026 05:29:10 +0200 Subject: [PATCH 071/110] =?UTF-8?q?=F0=9F=A7=A9=20Refactor(CFG):=20?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E6=B3=A8=E5=86=8C=E8=A1=A8=E6=A8=A1=E5=BC=8F?= =?UTF-8?q?=E3=80=81CFG=E4=BB=85=E5=AF=B9=E6=B3=A8=E5=86=8C=E8=A1=A8?= =?UTF-8?q?=E8=BF=9B=E8=A1=8C=E9=80=9A=E7=94=A8=E5=8C=96=E5=A4=84=E7=90=86?= =?UTF-8?q?=E8=80=8C=E4=B8=8D=E6=98=AF=E7=A1=AC=E7=BC=96=E7=A0=81=EF=BC=88?= =?UTF-8?q?1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../KitX.Core.BluePrint.Test/Program.cs | 12 +- .../BlockScripting/BlockStatementExtractor.cs | 45 --- .../BuiltinFunctions/BranchFunction.cs | 1 - .../BuiltinFunctions/BreakFunction.cs | 7 +- .../CreateWorkflowFunction.cs | 1 - .../BuiltinFunctions/GetFunction.cs | 53 +-- .../GetPluginInfoByNameFunction.cs | 1 - .../BuiltinFunctions/InstallPluginFunction.cs | 1 - .../BuiltinFunctions/JsonGetFieldFunction.cs | 1 - .../ListPluginNamesFunction.cs | 1 - .../BuiltinFunctions/ListWorkflowsFunction.cs | 1 - .../BuiltinFunctions/LoopFunction.cs | 2 - .../BuiltinFunctions/PauseFunction.cs | 1 - .../BuiltinFunctions/PluginCallFunction.cs | 1 - .../PluginCallWithTargetFunction.cs | 1 - .../BuiltinFunctions/PrintFunction.cs | 1 - .../BuiltinFunctions/ReadTextFileFunction.cs | 1 - .../BuiltinFunctions/RunWorkflowFunction.cs | 1 - .../BuiltinFunctions/SetFunction.cs | 51 +-- .../BuiltinFunctions/StartPluginFunction.cs | 1 - .../BuiltinFunctions/StopPluginFunction.cs | 1 - .../BuiltinFunctions/StopWorkflowFunction.cs | 1 - .../BuiltinFunctions/TryGetDeviceFunction.cs | 1 - .../BuiltinFunctions/WriteTextFileFunction.cs | 1 - .../BlockScripting/CFG2CSGenerator.cs | 37 +- .../IBuiltinFunctionDefinition.cs | 9 - .../BlockScriptToBlueprintConverter.cs | 2 +- .../BlueprintToBlockScriptConverter.cs | 2 - .../BuiltinFunctionExportStrategyAdapter.cs | 3 - .../Workflow/Blueprint/CFG/BP2CFGConverter.cs | 343 +++++++----------- .../Workflow/Blueprint/CFG/CFG2BSConverter.cs | 117 ++---- .../Workflow/Blueprint/NodeRegistry.cs | 14 +- .../Blueprint/Pipeline/BS2CFGConverter.cs | 145 ++++---- .../Blueprint/Pipeline/CFG2BPConverter.cs | 124 +++---- .../Blueprint/Pipeline/DataEdgeBuilder.cs | 136 +++---- .../Blueprint/Pipeline/PipelineContext.cs | 4 +- .../Workflow/WorkflowStorageService.cs | 4 +- KitX Clients/KitX Dashboard | 2 +- KitX Standard | 2 +- 39 files changed, 434 insertions(+), 698 deletions(-) diff --git a/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs b/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs index db49e483..e0270663 100644 --- a/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs +++ b/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs @@ -106,7 +106,7 @@ public static void Main(string[] args) Console.WriteLine("\n┌──────────────────────────────────────────┐"); Console.WriteLine("│ Test E: Manual Blueprint → Script │"); Console.WriteLine("└──────────────────────────────────────────┘\n"); - RunManualBlueprintTest(reverseConverter, "Test E"); + RunManualBlueprintTest(reverseConverter, nodeRegistry, "Test E"); } if (ShouldRunTest("F")) @@ -773,7 +773,7 @@ private static void RunRoundTripTest( // Test E: Manual Blueprint construction test // ────────────────────────────────────────────── private static void RunManualBlueprintTest( - IBlueprintToBlockScriptConverter reverseConverter, string label) + IBlueprintToBlockScriptConverter reverseConverter, INodeRegistry nodeRegistry, string label) { try { @@ -784,19 +784,19 @@ private static void RunManualBlueprintTest( // Entry → Print("Hello") → Branch(condition, "TrueBlock", "FalseBlock") var entry = new EntryNode(); - var printHello = new PrintNode(); + var printHello = nodeRegistry.CreateBuiltinFunctionNode("Print"); printHello.InputPins.First(p => p.Name == "Value").DefaultValue = "\"Hello\""; - var branch = new BranchNode(); + var branch = nodeRegistry.CreateBuiltinFunctionNode("Branch"); // ConstNode for condition var constTrue = new ConstNode { ConstName = "myCondition", ConstType = "bool", ConstValue = "true" }; // True branch: Print("Yes") - var printYes = new PrintNode(); + var printYes = nodeRegistry.CreateBuiltinFunctionNode("Print"); printYes.InputPins.First(p => p.Name == "Value").DefaultValue = "\"Yes\""; // False branch: Print("No") - var printNo = new PrintNode(); + var printNo = nodeRegistry.CreateBuiltinFunctionNode("Print"); printNo.InputPins.First(p => p.Name == "Value").DefaultValue = "\"No\""; bp.AddNode(entry); diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockStatementExtractor.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockStatementExtractor.cs index a86c3302..a368051b 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockStatementExtractor.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockStatementExtractor.cs @@ -277,52 +277,7 @@ private void ExtractStatements(SyntaxNode root, BlockDefinition block, BlockType } } - private FlowControlStatement CreateFlowControlStatement(InvocationExpressionSyntax invoke, FlowControlType type, int baseLine, string? fullExpressionText = null) - { - var args = invoke.ArgumentList.Arguments; - var statement = new FlowControlStatement - { - LineNumber = baseLine, - SourceCode = fullExpressionText ?? invoke.ToFullString(), - ControlType = type - }; - - Log.Debug("[BlockStatementExtractor] CreateFlowControlStatement: type={Type}, SourceCode={SourceCode}", - type, statement.SourceCode); - switch (type) - { - case FlowControlType.Branch: - case FlowControlType.Loop: - if (args.Count >= 1) - { - statement.ConditionExpression = args[0].Expression.ToString(); - Log.Debug("[BlockStatementExtractor] args[0] (condition): {Expr}", args[0].Expression.ToString()); - } - if (args.Count >= 2) - { - statement.TrueBlockName = GetStringLiteral(args[1].Expression); - Log.Debug("[BlockStatementExtractor] args[1] (trueBlock): raw={Raw}, extracted={Extracted}", - args[1].Expression.ToString(), statement.TrueBlockName); - } - if (args.Count >= 3) - { - statement.FalseBlockName = GetStringLiteral(args[2].Expression); - Log.Debug("[BlockStatementExtractor] args[2] (falseBlock): raw={Raw}, extracted={Extracted}", - args[2].Expression.ToString(), statement.FalseBlockName); - } - Log.Debug("[BlockStatementExtractor] Loop/Branch created: Condition={Condition}, TrueBlock={TrueBlock}, FalseBlock={FalseBlock}", - statement.ConditionExpression, statement.TrueBlockName, statement.FalseBlockName); - break; - - case FlowControlType.ToLoopCond: - if (args.Count >= 1) - statement.ToLoopCondReturnTo = GetStringLiteral(args[0].Expression); - break; - } - - return statement; - } private static string GetStringLiteral(ExpressionSyntax expr) { diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/BranchFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/BranchFunction.cs index e7de6a7b..a3bf10b4 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/BranchFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/BranchFunction.cs @@ -18,7 +18,6 @@ public class BranchFunction : IBuiltinFunctionDefinition public string DisplayName => "Branch"; public bool IsFlowControl => true; public bool IsNonExtractable => false; - public BlueprintNodeType? LegacyNodeType => BlueprintNodeType.Branch; public bool IsBlockTerminator => true; public CFGStatementKind StatementKind => CFGStatementKind.Branch; public double NodeWidth => 120; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/BreakFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/BreakFunction.cs index cbefb379..36aefec4 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/BreakFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/BreakFunction.cs @@ -15,10 +15,9 @@ public class BreakFunction : IBuiltinFunctionDefinition public string FunctionName => "Break"; public string DisplayName => "Break"; public bool IsFlowControl => true; - public bool IsBlockTerminator => true; - public bool IsNonExtractable => true; - public BlueprintNodeType? LegacyNodeType => BlueprintNodeType.Break; - public CFGStatementKind StatementKind => CFGStatementKind.Break; + public bool IsBlockTerminator => true; + public bool IsNonExtractable => true; + public CFGStatementKind StatementKind => CFGStatementKind.Break; public double NodeWidth => 100; public double NodeHeight => 40; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/CreateWorkflowFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/CreateWorkflowFunction.cs index 8594fffa..0ebb208a 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/CreateWorkflowFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/CreateWorkflowFunction.cs @@ -16,7 +16,6 @@ public class CreateWorkflowFunction : IBuiltinFunctionDefinition public string DisplayName => "Create Workflow"; public bool IsFlowControl => false; public bool IsNonExtractable => true; - public BlueprintNodeType? LegacyNodeType => null; public CFGStatementKind StatementKind => CFGStatementKind.Expression; public double NodeWidth => 180; public double NodeHeight => 80; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/GetFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/GetFunction.cs index 2620a2ea..d73f7cf7 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/GetFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/GetFunction.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Linq; using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; using KitX.Core.Workflow.Blueprint; @@ -8,7 +9,9 @@ namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions; /// -/// Get 内置函数 — 读取变量值。 +/// Get builtin function — reads a variable value from global scope. +/// BlockScript syntax: Get("varName") +/// VarName is passed as the first input pin (String type), allowing connections. /// public class GetFunction : IBuiltinFunctionDefinition { @@ -16,13 +19,13 @@ public class GetFunction : IBuiltinFunctionDefinition public string DisplayName => "Get"; public bool IsFlowControl => false; public bool IsNonExtractable => false; - public BlueprintNodeType? LegacyNodeType => BlueprintNodeType.Get; public CFGStatementKind StatementKind => CFGStatementKind.Assignment; - public double NodeWidth => 120; + public double NodeWidth => 140; public double NodeHeight => 60; public IReadOnlyList InputPins => [ - new("Exec", PinType.Execution, 20) + new("Exec", PinType.Execution, 20), + new("VarName", PinType.String, 40) ]; public IReadOnlyList OutputPins => [ @@ -30,7 +33,6 @@ public class GetFunction : IBuiltinFunctionDefinition new("Value", PinType.Any, 40) ]; - public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; public List FormatInvocation( @@ -39,14 +41,6 @@ public List FormatInvocation( { var args = invoke.ArgumentList.Arguments.Select(a => a.Expression.ToString()).ToList(); - // Extract varName from first argument - string? getVarName = null; - if (invoke.ArgumentList.Arguments.Count > 0) - { - var firstArgExpr = invoke.ArgumentList.Arguments[0].Expression; - getVarName = ExprUtils.GetStringLiteralValue(firstArgExpr) ?? args[0]; - } - // Auto-generate PubVar if not already assigned string? pubVarTarget; if (string.IsNullOrEmpty(assignedVar) || !context.PubVarNames.Contains(assignedVar)) @@ -66,7 +60,6 @@ public List FormatInvocation( Kind = CFGStatementKind.Assignment, FunctionName = FunctionName, PubVarTarget = pubVarTarget, - GetVarName = getVarName, Arguments = args, OriginalExpression = invoke.ToString(), SourceLine = 0, @@ -75,11 +68,14 @@ public List FormatInvocation( public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) { - var varName = stmt.GetVarName ?? (stmt.Arguments?.Count > 0 ? stmt.Arguments[0].Trim('"') : ""); - if (node is GetNode gn) - gn.VarName = varName; - else if (node is BuiltinFunctionNode bfn) - bfn.Properties["VarName"] = varName; + // Set default value on VarName pin from first argument + if (stmt.Arguments?.Count > 0) + { + var varName = stmt.Arguments[0].Trim('"'); + var pin = node.InputPins.FirstOrDefault(p => p.Name == "VarName"); + if (pin != null) + pin.DefaultValue = varName; + } return node; } @@ -87,13 +83,6 @@ public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) InvocationExpressionSyntax invoke, List expandedArgs, string? assignedVar, PipelineContext context) { - string? getVarName = null; - if (expandedArgs.Count > 0) - { - var firstArgExpr = invoke.ArgumentList.Arguments[0].Expression; - getVarName = ExprUtils.GetStringLiteralValue(firstArgExpr) ?? expandedArgs[0]; - } - string? pubVarTarget; if (string.IsNullOrEmpty(assignedVar) || !context.PubVarNames.Contains(assignedVar)) { @@ -106,7 +95,7 @@ public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) pubVarTarget = assignedVar; } - return (null, getVarName, pubVarTarget); + return (null, null, pubVarTarget); } public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) @@ -117,12 +106,10 @@ public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) var pubVar = helper.GetOutputPubVar(node, "Value"); if (pubVar == null) return null; - var varName = node switch - { - GetNode gn => gn.VarName, - BuiltinFunctionNode bfn => bfn.Properties.GetValueOrDefault("VarName", ""), - _ => "" - }; + var varPin = node.InputPins.FirstOrDefault(p => p.Name == "VarName"); + var varName = varPin?.DefaultValue ?? ""; + if (string.IsNullOrEmpty(varName)) return null; + return new ExpressionStatement { Expression = $"Get(\"{varName}\")", diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/GetPluginInfoByNameFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/GetPluginInfoByNameFunction.cs index c718e13a..77416488 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/GetPluginInfoByNameFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/GetPluginInfoByNameFunction.cs @@ -18,7 +18,6 @@ public class GetPluginInfoByNameFunction : IBuiltinFunctionDefinition public string DisplayName => "Get Plugin Info"; public bool IsFlowControl => false; public bool IsNonExtractable => true; - public BlueprintNodeType? LegacyNodeType => null; public CFGStatementKind StatementKind => CFGStatementKind.Expression; public double NodeWidth => 160; public double NodeHeight => 60; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/InstallPluginFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/InstallPluginFunction.cs index 08fc7585..5797010b 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/InstallPluginFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/InstallPluginFunction.cs @@ -17,7 +17,6 @@ public class InstallPluginFunction : IBuiltinFunctionDefinition public string DisplayName => "Install Plugin"; public bool IsFlowControl => false; public bool IsNonExtractable => true; - public BlueprintNodeType? LegacyNodeType => null; public CFGStatementKind StatementKind => CFGStatementKind.Expression; public double NodeWidth => 140; public double NodeHeight => 60; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/JsonGetFieldFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/JsonGetFieldFunction.cs index 8afa0eef..2a878252 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/JsonGetFieldFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/JsonGetFieldFunction.cs @@ -16,7 +16,6 @@ public class JsonGetFieldFunction : IBuiltinFunctionDefinition public string DisplayName => "JSON Get Field"; public bool IsFlowControl => false; public bool IsNonExtractable => true; - public BlueprintNodeType? LegacyNodeType => null; public CFGStatementKind StatementKind => CFGStatementKind.Expression; public double NodeWidth => 160; public double NodeHeight => 80; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/ListPluginNamesFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/ListPluginNamesFunction.cs index 1050c397..e34408bc 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/ListPluginNamesFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/ListPluginNamesFunction.cs @@ -18,7 +18,6 @@ public class ListPluginNamesFunction : IBuiltinFunctionDefinition public string DisplayName => "List Plugin Names"; public bool IsFlowControl => false; public bool IsNonExtractable => true; - public BlueprintNodeType? LegacyNodeType => null; public CFGStatementKind StatementKind => CFGStatementKind.Expression; public double NodeWidth => 140; public double NodeHeight => 60; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/ListWorkflowsFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/ListWorkflowsFunction.cs index 73d1d95f..ac387da6 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/ListWorkflowsFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/ListWorkflowsFunction.cs @@ -17,7 +17,6 @@ public class ListWorkflowsFunction : IBuiltinFunctionDefinition public string DisplayName => "List Workflows"; public bool IsFlowControl => false; public bool IsNonExtractable => true; - public BlueprintNodeType? LegacyNodeType => null; public CFGStatementKind StatementKind => CFGStatementKind.Expression; public double NodeWidth => 140; public double NodeHeight => 60; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/LoopFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/LoopFunction.cs index e39d31db..746bcb6f 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/LoopFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/LoopFunction.cs @@ -19,7 +19,6 @@ public class LoopFunction : IBuiltinFunctionDefinition public string DisplayName => "Loop"; public bool IsFlowControl => true; public bool IsNonExtractable => false; - public BlueprintNodeType? LegacyNodeType => BlueprintNodeType.Loop; public bool IsBlockTerminator => true; public CFGStatementKind StatementKind => CFGStatementKind.Loop; public double NodeWidth => 120; @@ -76,7 +75,6 @@ public void OnNodeCreated(BlueprintNode node, CFGStatement stmt, PipelineContext }); } - context.LoopDefs.Add((stmt.StatementId, stmt.TrueBlockName, stmt.FalseBlockName, stmt.BlockName)); context.LoopNodesByParent[stmt.BlockName] = node; } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PauseFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PauseFunction.cs index 2feeb12f..31ced3d9 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PauseFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PauseFunction.cs @@ -17,7 +17,6 @@ public class PauseFunction : IBuiltinFunctionDefinition public string DisplayName => "Pause"; public bool IsFlowControl => false; public bool IsNonExtractable => true; - public BlueprintNodeType? LegacyNodeType => BlueprintNodeType.Pause; public CFGStatementKind StatementKind => CFGStatementKind.Pause; public double NodeWidth => 100; public double NodeHeight => 50; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PluginCallFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PluginCallFunction.cs index cb9b2117..6390ffed 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PluginCallFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PluginCallFunction.cs @@ -20,7 +20,6 @@ public class PluginCallFunction : IBuiltinFunctionDefinition public string DisplayName => "PluginCall"; public bool IsFlowControl => false; public bool IsNonExtractable => true; - public BlueprintNodeType? LegacyNodeType => null; public CFGStatementKind StatementKind => CFGStatementKind.Expression; public double NodeWidth => 140; public double NodeHeight => 80; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PluginCallWithTargetFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PluginCallWithTargetFunction.cs index e7cf99f3..a56fcc35 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PluginCallWithTargetFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PluginCallWithTargetFunction.cs @@ -23,7 +23,6 @@ public class PluginCallWithTargetFunction : IBuiltinFunctionDefinition public string DisplayName => "PluginCallWithTarget"; public bool IsFlowControl => false; public bool IsNonExtractable => false; - public BlueprintNodeType? LegacyNodeType => BlueprintNodeType.Call; public CFGStatementKind StatementKind => CFGStatementKind.PluginCallWithTarget; public double NodeWidth => 140; public double NodeHeight => 80; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PrintFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PrintFunction.cs index 98ac7dc8..55999fae 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PrintFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PrintFunction.cs @@ -16,7 +16,6 @@ public class PrintFunction : IBuiltinFunctionDefinition public string DisplayName => "Print"; public bool IsFlowControl => false; public bool IsNonExtractable => true; - public BlueprintNodeType? LegacyNodeType => BlueprintNodeType.Print; public CFGStatementKind StatementKind => CFGStatementKind.Print; public double NodeWidth => 100; public double NodeHeight => 50; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/ReadTextFileFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/ReadTextFileFunction.cs index 0f89d567..77192d09 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/ReadTextFileFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/ReadTextFileFunction.cs @@ -15,7 +15,6 @@ public class ReadTextFileFunction : IBuiltinFunctionDefinition public string DisplayName => "Read Text File"; public bool IsFlowControl => false; public bool IsNonExtractable => true; - public BlueprintNodeType? LegacyNodeType => null; public CFGStatementKind StatementKind => CFGStatementKind.Expression; public double NodeWidth => 140; public double NodeHeight => 60; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/RunWorkflowFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/RunWorkflowFunction.cs index d7e0ef25..3f15a230 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/RunWorkflowFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/RunWorkflowFunction.cs @@ -16,7 +16,6 @@ public class RunWorkflowFunction : IBuiltinFunctionDefinition public string DisplayName => "Run Workflow"; public bool IsFlowControl => false; public bool IsNonExtractable => true; - public BlueprintNodeType? LegacyNodeType => null; public CFGStatementKind StatementKind => CFGStatementKind.Expression; public double NodeWidth => 140; public double NodeHeight => 60; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/SetFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/SetFunction.cs index 27fe9dc8..ac07d1e6 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/SetFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/SetFunction.cs @@ -9,7 +9,9 @@ namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions { /// - /// Set 内置函数 — 设置变量值。 + /// Set builtin function — writes a value to a global variable. + /// BlockScript syntax: Set("varName", value) + /// VarName is passed as the first input pin (String type), Value as the second. /// public class SetFunction : IBuiltinFunctionDefinition { @@ -17,14 +19,14 @@ public class SetFunction : IBuiltinFunctionDefinition public string DisplayName => "Set"; public bool IsFlowControl => false; public bool IsNonExtractable => true; - public BlueprintNodeType? LegacyNodeType => BlueprintNodeType.Set; public CFGStatementKind StatementKind => CFGStatementKind.Set; - public double NodeWidth => 120; + public double NodeWidth => 160; public double NodeHeight => 60; public IReadOnlyList InputPins => [ new("Exec", PinType.Execution, 20), - new("Value", PinType.Any, 40) + new("VarName", PinType.String, 35), + new("Value", PinType.Any, 50) ]; public IReadOnlyList OutputPins => [ @@ -39,21 +41,11 @@ public List FormatInvocation( { var currentArgExprs = invoke.ArgumentList.Arguments.Select(a => a.Expression.ToString()).ToList(); - string? setVarName = null; - if (invoke.ArgumentList.Arguments.Count > 0) - { - var firstArgExpr = invoke.ArgumentList.Arguments[0].Expression; - var varNameLiteral = ExprUtils.GetStringLiteralValue(firstArgExpr); - setVarName = varNameLiteral ?? currentArgExprs[0]; - currentArgExprs.RemoveAt(0); - } - return [new CFGStatement { BlockName = blockName, Kind = CFGStatementKind.Set, FunctionName = FunctionName, - SetVarName = setVarName, Arguments = currentArgExprs, OriginalExpression = invoke.ToString(), SourceLine = 0, @@ -62,11 +54,14 @@ public List FormatInvocation( public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) { - var varName = stmt.SetVarName ?? ""; - if (node is SetNode sn) - sn.VarName = varName; - else if (node is BuiltinFunctionNode bfn) - bfn.Properties["VarName"] = varName; + // Set default value on VarName pin from first argument + if (stmt.Arguments?.Count > 0) + { + var varName = stmt.Arguments[0].Trim('"'); + var varPin = node.InputPins.FirstOrDefault(p => p.Name == "VarName"); + if (varPin != null) + varPin.DefaultValue = varName; + } return node; } @@ -74,24 +69,14 @@ public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) InvocationExpressionSyntax invoke, List expandedArgs, string? assignedVar, PipelineContext context) { - string? setVarName = null; - if (expandedArgs.Count > 0) - { - var firstArgExpr = invoke.ArgumentList.Arguments[0].Expression; - setVarName = ExprUtils.GetStringLiteralValue(firstArgExpr) ?? expandedArgs[0]; - expandedArgs.RemoveAt(0); - } - return (setVarName, null, null); + // Don't strip first arg — it stays as the VarName pin argument + return (null, null, null); } public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) { - var varName = node switch - { - SetNode sn => sn.VarName, - BuiltinFunctionNode bfn => bfn.Properties.GetValueOrDefault("VarName", ""), - _ => "" - }; + var varPin = node.InputPins.FirstOrDefault(p => p.Name == "VarName"); + var varName = varPin?.DefaultValue ?? ""; var value = helper.GetInputValue(node, "Value"); return new ExpressionStatement { diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/StartPluginFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/StartPluginFunction.cs index d314b39e..360fa803 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/StartPluginFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/StartPluginFunction.cs @@ -17,7 +17,6 @@ public class StartPluginFunction : IBuiltinFunctionDefinition public string DisplayName => "Start Plugin"; public bool IsFlowControl => false; public bool IsNonExtractable => true; - public BlueprintNodeType? LegacyNodeType => null; public CFGStatementKind StatementKind => CFGStatementKind.Expression; public double NodeWidth => 140; public double NodeHeight => 60; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/StopPluginFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/StopPluginFunction.cs index b0951db9..c0eb0b32 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/StopPluginFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/StopPluginFunction.cs @@ -17,7 +17,6 @@ public class StopPluginFunction : IBuiltinFunctionDefinition public string DisplayName => "Stop Plugin"; public bool IsFlowControl => false; public bool IsNonExtractable => true; - public BlueprintNodeType? LegacyNodeType => null; public CFGStatementKind StatementKind => CFGStatementKind.Expression; public double NodeWidth => 140; public double NodeHeight => 60; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/StopWorkflowFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/StopWorkflowFunction.cs index c7596e13..6a56f74f 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/StopWorkflowFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/StopWorkflowFunction.cs @@ -16,7 +16,6 @@ public class StopWorkflowFunction : IBuiltinFunctionDefinition public string DisplayName => "Stop Workflow"; public bool IsFlowControl => false; public bool IsNonExtractable => true; - public BlueprintNodeType? LegacyNodeType => null; public CFGStatementKind StatementKind => CFGStatementKind.Expression; public double NodeWidth => 140; public double NodeHeight => 60; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/TryGetDeviceFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/TryGetDeviceFunction.cs index 9c4abc03..b8cde159 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/TryGetDeviceFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/TryGetDeviceFunction.cs @@ -27,7 +27,6 @@ public class TryGetDeviceFunction : IBuiltinFunctionDefinition public string DisplayName => "TryGetDevice"; public bool IsFlowControl => false; public bool IsNonExtractable => false; - public BlueprintNodeType? LegacyNodeType => null; public CFGStatementKind StatementKind => CFGStatementKind.TryGetDevice; public double NodeWidth => 120; public double NodeHeight => 60; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/WriteTextFileFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/WriteTextFileFunction.cs index 26be1aba..565b3cf4 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/WriteTextFileFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/WriteTextFileFunction.cs @@ -15,7 +15,6 @@ public class WriteTextFileFunction : IBuiltinFunctionDefinition public string DisplayName => "Write Text File"; public bool IsFlowControl => false; public bool IsNonExtractable => true; - public BlueprintNodeType? LegacyNodeType => null; public CFGStatementKind StatementKind => CFGStatementKind.Expression; public double NodeWidth => 160; public double NodeHeight => 80; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/CFG2CSGenerator.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/CFG2CSGenerator.cs index 64ccbcc9..146cc866 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/CFG2CSGenerator.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/CFG2CSGenerator.cs @@ -563,7 +563,23 @@ internal static SwitchSectionSyntax GenerateFormattedBlockCase( if (stmt.FunctionName == "Get") { - rawExpr = BuildGetInvocation(stmt.GetVarName ?? ""); + var varName = stmt.Arguments?.Count > 0 ? stmt.Arguments[0].Trim('"') : ""; + rawExpr = BuildGetInvocation(varName); + } + else if (stmt.FunctionName == "Set") + { + var varName = stmt.Arguments?.Count > 0 ? stmt.Arguments[0].Trim('"') : ""; + var valueExpr = stmt.Arguments?.Count > 1 + ? ResolveArgumentExpression(stmt.Arguments[1], pubVarTypes) + : LiteralExpression(SyntaxKind.NullLiteralExpression); + rawExpr = InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("G"), IdentifierName("Set")), + ArgumentList(SeparatedList(new[] + { + Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(varName))), + Argument(valueExpr) + }))); } else if (IsHelperFunction(stmt.FunctionName, helperFunctions)) { @@ -678,9 +694,24 @@ internal static SwitchSectionSyntax GenerateFormattedBlockCase( case CFGStatementKind.Set: { - var varName = stmt.SetVarName ?? ""; - if (stmt.Arguments.Count > 0) + var varName = stmt.Arguments?.Count > 0 ? stmt.Arguments[0].Trim('"') : ""; + if (stmt.Arguments.Count > 1) + { + var valueExpr = ResolveArgumentExpression(stmt.Arguments[1], pubVarTypes); + caseStatements.Add( + ExpressionStatement( + InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("G"), IdentifierName("Set")), + ArgumentList(SeparatedList(new[] + { + Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(varName))), + Argument(valueExpr) + }))))); + } + else if (stmt.Arguments.Count > 0) { + // Single-arg case (legacy): use first arg as value, varName might be empty var valueExpr = ResolveArgumentExpression(stmt.Arguments[0], pubVarTypes); caseStatements.Add( ExpressionStatement( diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/IBuiltinFunctionDefinition.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/IBuiltinFunctionDefinition.cs index 5f3bfaa0..32c03023 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/IBuiltinFunctionDefinition.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/IBuiltinFunctionDefinition.cs @@ -108,15 +108,6 @@ List FormatInvocation( /// 返回控制流输出臂描述。非控制流函数返回空集合。 IEnumerable GetOutputArms(); - // ─── 向后兼容 ────────────────────────────────────── - - /// - /// 对应的旧版 BlueprintNodeType。用于反向转换时将导出策略映射到正确的节点类型。 - /// 标准函数(Print/Set/Get/Branch/Loop/Break/Pause)返回对应的枚举值; - /// 纯新增函数(如 Flip)返回 null。 - /// - BlueprintNodeType? LegacyNodeType => null; - // ─── 控制流(可选,默认实现为无操作)────────────── /// diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlockScriptToBlueprintConverter.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlockScriptToBlueprintConverter.cs index 297b763c..eed697ac 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlockScriptToBlueprintConverter.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlockScriptToBlueprintConverter.cs @@ -84,7 +84,7 @@ public Contract.Workflow.Blueprint Convert(BlockScript script) context.AllNodes.Count, context.ExecEdges.Count); // ── Phase 4+5: Data edges + deduplication ── - var dataEdgeBuilder = new DataEdgeBuilder(); + var dataEdgeBuilder = new DataEdgeBuilder(_functionRegistry); dataEdgeBuilder.Build(context); Log.Debug("[Converter] Phase 4+5: {DataEdgeCount} data edges", context.DataEdges.Count); diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintToBlockScriptConverter.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintToBlockScriptConverter.cs index 160c76ad..e8d31212 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintToBlockScriptConverter.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintToBlockScriptConverter.cs @@ -32,8 +32,6 @@ public BlueprintToBlockScriptConverter(IEnumerable strategi if (s is BuiltinFunctionExportStrategyAdapter adapter) { _builtinMap[adapter.FunctionName] = s; - if (adapter.LegacyNodeType != null) - _strategyMap[adapter.LegacyNodeType.Value] = s; } else { diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BuiltinFunctionExportStrategyAdapter.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BuiltinFunctionExportStrategyAdapter.cs index 8ddad021..9976310b 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BuiltinFunctionExportStrategyAdapter.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BuiltinFunctionExportStrategyAdapter.cs @@ -24,9 +24,6 @@ public BuiltinFunctionExportStrategyAdapter(IBuiltinFunctionDefinition definitio /// public BlueprintNodeType NodeType => BlueprintNodeType.BuiltinFunction; - /// 代理底层定义的 LegacyNodeType,用于反向映射时识别具体的蓝图节点类型。 - public BlueprintNodeType? LegacyNodeType => _definition.LegacyNodeType; - /// 代理底层定义的 StatementKind,用于 BP→CFG 时直接填充 CFGStatement 字段。 public CFGStatementKind StatementKind => _definition.StatementKind; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/BP2CFGConverter.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/BP2CFGConverter.cs index 5ddbfb57..ce6c25f7 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/BP2CFGConverter.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/BP2CFGConverter.cs @@ -210,7 +210,7 @@ private static void AnalyzeConnections( if (sourceNode == null) continue; if (sourceNode.NodeType == BlueprintNodeType.Const) continue; - if (sourceNode.NodeType is BlueprintNodeType.Get or BlueprintNodeType.Call + if (sourceNode.NodeType is BlueprintNodeType.Call or BlueprintNodeType.CallHelper || (sourceNode is BuiltinFunctionNode bfn && bfn.FunctionName == "Get")) { @@ -371,11 +371,13 @@ private void WalkNode( } var stmt = GenerateStatement(node, nodeById, execConns, blueprint); + var isControlFlow = IsControlFlowNode(node); + if (stmt != null) { currentBlock.Statements.Add(stmt); - if (stmt.Kind == CFGStatementKind.Branch || stmt.Kind == CFGStatementKind.Loop) + if (isControlFlow) { pendingControlFlowNodes.Add(node); if (stmt.Kind == CFGStatementKind.Loop) @@ -390,7 +392,7 @@ private void WalkNode( if (stmt is { Kind: CFGStatementKind.ToLoopCond }) return; - if (IsBranchNode(node) || IsLoopNode(node)) + if (isControlFlow) { pendingControlFlowNodes.Add(node); return; @@ -445,26 +447,37 @@ private void ProcessSubGraphs( if (processedNodes.Contains(node.Id)) continue; processedNodes.Add(node.Id); - if (IsBranchNode(node)) - ProcessBranchSubGraph(node, cfg, nodeById, reachableNodeIds, execConns, - blueprint, loopNodes, loopOwnerBlockNames, ref blockCounter); - else if (IsLoopNode(node)) - ProcessLoopSubGraph(node, cfg, nodeById, reachableNodeIds, execConns, - blueprint, loopNodes, loopOwnerBlockNames, ref blockCounter); + ProcessControlFlowSubGraph(node, cfg, nodeById, reachableNodeIds, execConns, + blueprint, loopNodes, loopOwnerBlockNames, ref blockCounter); } } } - private void ProcessBranchSubGraph( - BlueprintNode branchNode, ControlFlowGraph cfg, + private void ProcessControlFlowSubGraph( + BlueprintNode cfNode, ControlFlowGraph cfg, Dictionary nodeById, HashSet reachableNodeIds, List execConns, Contract.Workflow.Blueprint blueprint, Dictionary loopNodes, Dictionary loopOwnerBlockNames, ref int blockCounter) { - foreach (var pinName in new[] { True, False }) + // Get output arms from the export strategy + IEnumerable? arms = null; + + if (cfNode is BuiltinFunctionNode bfn + && _builtinFunctionStrategies.TryGetValue(bfn.FunctionName, out var bfStrat)) + { + arms = bfStrat.GetOutputArms(cfNode); + } + else if (_strategies.TryGetValue(cfNode.NodeType, out var strat)) + { + arms = strat.GetOutputArms(cfNode); + } + + if (arms == null) return; + + foreach (var arm in arms) { - var pin = branchNode.OutputPins.FirstOrDefault(p => p.Name == pinName); + var pin = cfNode.OutputPins.FirstOrDefault(p => p.Name == arm.PinName); if (pin == null) continue; var conn = execConns.FirstOrDefault(c => c.SourcePinId == pin.Id); @@ -474,80 +487,36 @@ private void ProcessBranchSubGraph( if (targetNode == null) continue; var blockName = $"Block_{blockCounter++}"; - var block = new CFGBlock { Name = blockName, Type = CFGBlockType.Basic }; + var block = new CFGBlock + { + Name = blockName, + Type = arm.IsLoopback ? CFGBlockType.LoopBody : CFGBlockType.Basic, + ParentLoopBlockName = arm.IsLoopback ? FindContainingBlockName(cfg, cfNode.Id) : null + }; cfg.Blocks.Add(block); + if (arm.IsLoopback) + loopOwnerBlockNames[cfNode.Id] = blockName; + WalkNode(targetNode, block, cfg, nodeById, reachableNodeIds, execConns, blueprint, new HashSet(), pendingControlFlowNodes: new(), - loopNodes, loopOwnerBlockNames, ref blockCounter, loopbackTargetId: null); + loopNodes, loopOwnerBlockNames, ref blockCounter, + loopbackTargetId: arm.IsLoopback ? cfNode.Id : null); - // Update the Branch statement in the parent block - var branchStmt = FindBranchStatement(cfg, branchNode.Id); - if (branchStmt != null) + // Update the CFG statement with the resolved target block name + var cfStmt = FindBranchStatement(cfg, cfNode.Id); + if (cfStmt != null) { - if (pinName == True) branchStmt.TrueBlockName = blockName; - else branchStmt.FalseBlockName = blockName; - branchStmt.OriginalExpression = RegenerateBranchSource(branchStmt); - } - } - } - - private void ProcessLoopSubGraph( - BlueprintNode loopNode, ControlFlowGraph cfg, - Dictionary nodeById, HashSet reachableNodeIds, - List execConns, Contract.Workflow.Blueprint blueprint, - Dictionary loopNodes, - Dictionary loopOwnerBlockNames, ref int blockCounter) - { - // Loop body - var loopBodyPin = loopNode.OutputPins.FirstOrDefault(p => p.Name == LoopBody); - if (loopBodyPin != null) - { - var conn = execConns.FirstOrDefault(c => c.SourcePinId == loopBodyPin.Id); - if (conn != null) - { - var targetNode = blueprint.GetNodeById(conn.TargetNodeId); - if (targetNode != null) - { - var blockName = $"Block_{blockCounter++}"; - var block = new CFGBlock - { - Name = blockName, - Type = CFGBlockType.LoopBody, - ParentLoopBlockName = FindContainingBlockName(cfg, loopNode.Id) - }; - cfg.Blocks.Add(block); - loopOwnerBlockNames[loopNode.Id] = blockName; - - WalkNode(targetNode, block, cfg, nodeById, reachableNodeIds, execConns, - blueprint, new HashSet(), pendingControlFlowNodes: new(), - loopNodes, loopOwnerBlockNames, ref blockCounter, loopbackTargetId: loopNode.Id); - } - } - } - - // Loop exit - var loopEndPin = loopNode.OutputPins.FirstOrDefault(p => p.Name == LoopEnd); - if (loopEndPin != null) - { - var conn = execConns.FirstOrDefault(c => c.SourcePinId == loopEndPin.Id); - if (conn != null) - { - var targetNode = blueprint.GetNodeById(conn.TargetNodeId); - if (targetNode != null) - { - var blockName = $"Block_{blockCounter++}"; - var block = new CFGBlock - { - Name = blockName, - Type = CFGBlockType.LoopExit - }; - cfg.Blocks.Add(block); - - WalkNode(targetNode, block, cfg, nodeById, reachableNodeIds, execConns, - blueprint, new HashSet(), pendingControlFlowNodes: new(), - loopNodes, loopOwnerBlockNames, ref blockCounter, loopbackTargetId: null); - } + // Map arm pin name to TrueBlockName/FalseBlockName + // First non-loopback arm → TrueBlockName, second → FalseBlockName + var normalArms = arms.Where(a => !a.IsLoopback).ToList(); + var armIndex = normalArms.IndexOf(arm); + if (armIndex == 0) + cfStmt.TrueBlockName = blockName; + else if (armIndex == 1) + cfStmt.FalseBlockName = blockName; + + cfStmt.OriginalExpression = RegenerateBranchSource(cfStmt); } } } @@ -556,64 +525,66 @@ private void ProcessLoopSubGraph( // Step 4: Build CFG Edges from Blueprint Topology // ════════════════════════════════════════════════════════════════════ - private static void BuildEdgesFromTopology( + private void BuildEdgesFromTopology( ControlFlowGraph cfg, Contract.Workflow.Blueprint blueprint, Dictionary nodeById, List execConns) { foreach (var block in cfg.Blocks) { - // Skip blocks that already have edges from WalkNode if (block.Successors.Count > 0) continue; - - // Skip blocks that end with control flow (edges added from statements) if (block.EndsWithControlFlow) continue; - - // For BlockScopes-based blocks, derive edges from the last node's exec connection if (block.Statements.Count == 0) continue; var lastStmt = block.Statements[^1]; - if (lastStmt.Kind == CFGStatementKind.Branch) + + // Use registry to determine edge types for control flow statements + if (!string.IsNullOrEmpty(lastStmt.FunctionName) + && _builtinFunctionStrategies.TryGetValue(lastStmt.FunctionName, out var builtinStrat) + && builtinStrat.IsControlFlow) { - if (!string.IsNullOrEmpty(lastStmt.TrueBlockName)) - block.Successors.Add(new CFGEdge - { - FromBlockName = block.Name, - ToBlockName = lastStmt.TrueBlockName, - Type = CFGEdgeType.BranchTrue, - PinName = True - }); - if (!string.IsNullOrEmpty(lastStmt.FalseBlockName)) + var arms = builtinStrat.GetOutputArms(null!); + foreach (var arm in arms) + { + var targetBlock = arm.IsLoopback ? lastStmt.ToLoopCondReturnTo + : (IsFirstNormalArm(arms, arm) ? lastStmt.TrueBlockName : lastStmt.FalseBlockName); + + if (string.IsNullOrEmpty(targetBlock)) continue; + block.Successors.Add(new CFGEdge { FromBlockName = block.Name, - ToBlockName = lastStmt.FalseBlockName, - Type = CFGEdgeType.BranchFalse, - PinName = False + ToBlockName = targetBlock, + Type = GetEdgeType(arm), + PinName = arm.PinName }); + } continue; } - if (lastStmt.Kind == CFGStatementKind.Loop) + // Non-registry control flow handling (fallback) + if (lastStmt.Kind == CFGStatementKind.ToLoopCond) { - if (!string.IsNullOrEmpty(lastStmt.TrueBlockName)) - block.Successors.Add(new CFGEdge - { - FromBlockName = block.Name, - ToBlockName = lastStmt.TrueBlockName, - Type = CFGEdgeType.LoopBody, - PinName = LoopBody - }); - if (!string.IsNullOrEmpty(lastStmt.FalseBlockName)) + if (!string.IsNullOrEmpty(lastStmt.ToLoopCondReturnTo)) block.Successors.Add(new CFGEdge { FromBlockName = block.Name, - ToBlockName = lastStmt.FalseBlockName, - Type = CFGEdgeType.LoopExit, - PinName = LoopEnd + ToBlockName = lastStmt.ToLoopCondReturnTo, + Type = CFGEdgeType.LoopbackToCondition }); continue; } + if (lastStmt.Kind == CFGStatementKind.Break) + { + block.Successors.Add(new CFGEdge + { + FromBlockName = block.Name, + ToBlockName = "__break__", + Type = CFGEdgeType.Break + }); + continue; + } + if (lastStmt.Kind == CFGStatementKind.ToLoopCond) { if (!string.IsNullOrEmpty(lastStmt.ToLoopCondReturnTo)) @@ -928,6 +899,14 @@ private CFGStatement ConvertBlockStatementToCfgStatement(BlockStatement blockStm FlowControlType.Break => CFGStatementKind.Break, _ => CFGStatementKind.Unknown }; + cfgStmt.FunctionName = flow.ControlType switch + { + FlowControlType.Branch => "Branch", + FlowControlType.Loop => "Loop", + FlowControlType.ToLoopCond => "ToLoopCond", + FlowControlType.Break => "Break", + _ => null + }; cfgStmt.ConditionExpression = flow.ConditionExpression; cfgStmt.TrueBlockName = flow.TrueBlockName; cfgStmt.FalseBlockName = flow.FalseBlockName; @@ -968,18 +947,20 @@ private CFGStatement ConvertBlockStatementToCfgStatement(BlockStatement blockStm cfgStmt.Arguments.Add(_exportHelper.GetInputValue(node, pin.Name)); } - // SetVarName / GetVarName from node properties - if (node is SetNode sn && !string.IsNullOrEmpty(sn.VarName)) - cfgStmt.SetVarName = sn.VarName; - else if (node is GetNode gn && !string.IsNullOrEmpty(gn.VarName)) - cfgStmt.GetVarName = gn.VarName; - else if (node is BuiltinFunctionNode bfnProps) + // SetVarName / GetVarName from node input pins for Set/Get + if (node is BuiltinFunctionNode bfnProps) { - bfnProps.Properties.TryGetValue("VarName", out var vn); - if (!string.IsNullOrEmpty(vn)) + if (bfnProps.FunctionName == "Set") + { + var varPin = node.InputPins.FirstOrDefault(p => p.Name == "VarName"); + if (varPin != null && !string.IsNullOrEmpty(varPin.DefaultValue)) + cfgStmt.SetVarName = varPin.DefaultValue; + } + else if (bfnProps.FunctionName == "Get") { - if (bfnProps.FunctionName == "Set") cfgStmt.SetVarName = vn; - else if (bfnProps.FunctionName == "Get") cfgStmt.GetVarName = vn; + var varPin = node.InputPins.FirstOrDefault(p => p.Name == "VarName"); + if (varPin != null && !string.IsNullOrEmpty(varPin.DefaultValue)) + cfgStmt.GetVarName = varPin.DefaultValue; } } @@ -1039,98 +1020,34 @@ private static void ResolveControlFlowTargets( if (!string.IsNullOrEmpty(stmt.TrueBlockName) && !string.IsNullOrEmpty(stmt.FalseBlockName)) continue; - // Find the Blueprint node corresponding to this statement if (!nodeById.TryGetValue(stmt.StatementId, out var node)) continue; - if (stmt.Kind == CFGStatementKind.Branch) - ResolveBranchTargets(stmt, node, cfg, blueprint, execConns); - else if (stmt.Kind == CFGStatementKind.Loop) - ResolveLoopTargets(stmt, node, cfg, blueprint, execConns); + ResolveControlFlowTargetsForNode(stmt, node, cfg, blueprint, execConns); } } } - private static void ResolveBranchTargets( - CFGStatement stmt, BlueprintNode branchNode, + private static void ResolveControlFlowTargetsForNode( + CFGStatement stmt, BlueprintNode cfNode, ControlFlowGraph cfg, Contract.Workflow.Blueprint blueprint, List execConns) { - // True branch - if (string.IsNullOrEmpty(stmt.TrueBlockName)) - { - var truePin = branchNode.OutputPins.FirstOrDefault(p => p.Name == True); - if (truePin != null) - { - var conn = execConns.FirstOrDefault(c => c.SourcePinId == truePin.Id); - if (conn != null) - { - var targetBlock = FindBlockContainingNode(cfg, conn.TargetNodeId, blueprint); - if (targetBlock != null) - stmt.TrueBlockName = targetBlock; - } - } - } - - // False branch - if (string.IsNullOrEmpty(stmt.FalseBlockName)) + foreach (var pin in cfNode.OutputPins.Where(p => p.Type == PinType.Execution)) { - var falsePin = branchNode.OutputPins.FirstOrDefault(p => p.Name == False); - if (falsePin != null) - { - var conn = execConns.FirstOrDefault(c => c.SourcePinId == falsePin.Id); - if (conn != null) - { - var targetBlock = FindBlockContainingNode(cfg, conn.TargetNodeId, blueprint); - if (targetBlock != null) - stmt.FalseBlockName = targetBlock; - } - } - } + var conn = execConns.FirstOrDefault(c => c.SourcePinId == pin.Id); + if (conn == null) continue; - // Regenerate source expression with resolved names - if (!string.IsNullOrEmpty(stmt.TrueBlockName) || !string.IsNullOrEmpty(stmt.FalseBlockName)) - stmt.OriginalExpression = RegenerateBranchSource(stmt); - } + var targetBlock = FindBlockContainingNode(cfg, conn.TargetNodeId, blueprint); + if (targetBlock == null) continue; - private static void ResolveLoopTargets( - CFGStatement stmt, BlueprintNode loopNode, - ControlFlowGraph cfg, Contract.Workflow.Blueprint blueprint, - List execConns) - { - // LoopBody (True) branch - if (string.IsNullOrEmpty(stmt.TrueBlockName)) - { - var loopBodyPin = loopNode.OutputPins.FirstOrDefault(p => p.Name == LoopBody); - if (loopBodyPin != null) - { - var conn = execConns.FirstOrDefault(c => c.SourcePinId == loopBodyPin.Id); - if (conn != null) - { - var targetBlock = FindBlockContainingNode(cfg, conn.TargetNodeId, blueprint); - if (targetBlock != null) - stmt.TrueBlockName = targetBlock; - } - } + // Assign first unset target + if (string.IsNullOrEmpty(stmt.TrueBlockName)) + stmt.TrueBlockName = targetBlock; + else if (string.IsNullOrEmpty(stmt.FalseBlockName)) + stmt.FalseBlockName = targetBlock; } - // LoopEnd (False) branch - if (string.IsNullOrEmpty(stmt.FalseBlockName)) - { - var loopEndPin = loopNode.OutputPins.FirstOrDefault(p => p.Name == LoopEnd); - if (loopEndPin != null) - { - var conn = execConns.FirstOrDefault(c => c.SourcePinId == loopEndPin.Id); - if (conn != null) - { - var targetBlock = FindBlockContainingNode(cfg, conn.TargetNodeId, blueprint); - if (targetBlock != null) - stmt.FalseBlockName = targetBlock; - } - } - } - - // Regenerate source expression with resolved names if (!string.IsNullOrEmpty(stmt.TrueBlockName) || !string.IsNullOrEmpty(stmt.FalseBlockName)) stmt.OriginalExpression = RegenerateBranchSource(stmt); } @@ -1189,12 +1106,30 @@ private static string RegenerateBranchSource(CFGStatement branchStmt) : $"NextBlock = Branch({branchStmt.ConditionExpression}, \"{branchStmt.TrueBlockName}\", \"{branchStmt.FalseBlockName}\");"; } + private static bool IsFirstNormalArm(IEnumerable arms, OutputArmDescriptor target) + { + var normalArms = arms.Where(a => !a.IsLoopback).ToList(); + return normalArms.Count > 0 && normalArms[0].PinName == target.PinName; + } + + private static CFGEdgeType GetEdgeType(OutputArmDescriptor arm) + { + if (arm.IsLoopback) return CFGEdgeType.LoopbackToCondition; + // For normal arms: first → BranchTrue/LoopBody, second → BranchFalse/LoopEnd + // This is a simplified heuristic; more sophisticated logic can be added if needed + return CFGEdgeType.BranchTrue; + } + // ─── Node Type Helpers ──────────────────────────────────────────── - private static bool IsNodeType(BlueprintNode node, BlueprintNodeType type) => - node.NodeType == type || (node is BuiltinFunctionNode bfn && bfn.FunctionName == type.ToString()); + private bool IsControlFlowNode(BlueprintNode node) => + node is BuiltinFunctionNode bfn + && _builtinFunctionStrategies.TryGetValue(bfn.FunctionName, out var strat) + && strat.IsControlFlow; - private static bool IsBranchNode(BlueprintNode node) => IsNodeType(node, BlueprintNodeType.Branch); + private bool IsBranchNode(BlueprintNode node) => + node is BuiltinFunctionNode bfn && bfn.FunctionName == "Branch"; - private static bool IsLoopNode(BlueprintNode node) => IsNodeType(node, BlueprintNodeType.Loop); + private bool IsLoopNode(BlueprintNode node) => + node is BuiltinFunctionNode bfn && bfn.FunctionName == "Loop"; } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFG2BSConverter.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFG2BSConverter.cs index ec83d523..97a386af 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFG2BSConverter.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFG2BSConverter.cs @@ -104,92 +104,49 @@ public BlockScript Generate(ControlFlowGraph cfg) // ─── Statement Conversion ────────────────────────────────────────── + private static readonly Dictionary KindToControlType = new() + { + [CFGStatementKind.Branch] = FlowControlType.Branch, + [CFGStatementKind.Loop] = FlowControlType.Loop, + [CFGStatementKind.ToLoopCond] = FlowControlType.ToLoopCond, + [CFGStatementKind.Break] = FlowControlType.Break, + }; + private static BlockStatement? ConvertStatement(CFGStatement cfgStmt) { - BlockStatement? result = null; + // Control flow statements → FlowControlStatement + if (KindToControlType.TryGetValue(cfgStmt.Kind, out var controlType) && controlType != null) + { + return new FlowControlStatement + { + StatementId = cfgStmt.StatementId, + ControlType = controlType.Value, + ConditionExpression = cfgStmt.ConditionExpression ?? string.Empty, + TrueBlockName = cfgStmt.TrueBlockName ?? string.Empty, + FalseBlockName = cfgStmt.FalseBlockName ?? string.Empty, + ToLoopCondReturnTo = cfgStmt.ToLoopCondReturnTo, + SourceCode = cfgStmt.OriginalExpression, + LineNumber = cfgStmt.SourceLine + }; + } - switch (cfgStmt.Kind) + // NextBlockAssignment → skip + if (cfgStmt.Kind == CFGStatementKind.NextBlockAssignment) + return null; + + // Everything else → ExpressionStatement + if (!string.IsNullOrEmpty(cfgStmt.OriginalExpression)) { - case CFGStatementKind.Branch: - return new FlowControlStatement - { - StatementId = cfgStmt.StatementId, - ControlType = FlowControlType.Branch, - ConditionExpression = cfgStmt.ConditionExpression ?? string.Empty, - TrueBlockName = cfgStmt.TrueBlockName ?? string.Empty, - FalseBlockName = cfgStmt.FalseBlockName ?? string.Empty, - SourceCode = cfgStmt.OriginalExpression, - LineNumber = cfgStmt.SourceLine - }; - break; - - case CFGStatementKind.Loop: - return new FlowControlStatement - { - StatementId = cfgStmt.StatementId, - ControlType = FlowControlType.Loop, - ConditionExpression = cfgStmt.ConditionExpression ?? string.Empty, - TrueBlockName = cfgStmt.TrueBlockName ?? string.Empty, - FalseBlockName = cfgStmt.FalseBlockName ?? string.Empty, - SourceCode = cfgStmt.OriginalExpression, - LineNumber = cfgStmt.SourceLine - }; - break; - - case CFGStatementKind.ToLoopCond: - return new FlowControlStatement - { - StatementId = cfgStmt.StatementId, - ControlType = FlowControlType.ToLoopCond, - ToLoopCondReturnTo = cfgStmt.ToLoopCondReturnTo, - SourceCode = cfgStmt.OriginalExpression, - LineNumber = cfgStmt.SourceLine - }; - break; - - case CFGStatementKind.Break: - return new FlowControlStatement - { - StatementId = cfgStmt.StatementId, - ControlType = FlowControlType.Break, - SourceCode = cfgStmt.OriginalExpression, - LineNumber = cfgStmt.SourceLine - }; - break; - - case CFGStatementKind.Print: - case CFGStatementKind.Pause: - case CFGStatementKind.Set: - case CFGStatementKind.Get: - case CFGStatementKind.Assignment: - case CFGStatementKind.Expression: - return new ExpressionStatement - { - StatementId = cfgStmt.StatementId, - Expression = ExtractExpression(cfgStmt.OriginalExpression), - SourceCode = cfgStmt.OriginalExpression, - LineNumber = cfgStmt.SourceLine - }; - break; - - case CFGStatementKind.NextBlockAssignment: - return null; - - default: - if (!string.IsNullOrEmpty(cfgStmt.OriginalExpression)) - { - return new ExpressionStatement - { - StatementId = cfgStmt.StatementId, - Expression = ExtractExpression(cfgStmt.OriginalExpression), - SourceCode = cfgStmt.OriginalExpression, - LineNumber = cfgStmt.SourceLine - }; - } - break; + return new ExpressionStatement + { + StatementId = cfgStmt.StatementId, + Expression = ExtractExpression(cfgStmt.OriginalExpression), + SourceCode = cfgStmt.OriginalExpression, + LineNumber = cfgStmt.SourceLine + }; } - return result; + return null; } /// diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeRegistry.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeRegistry.cs index 99afaa40..105bdf72 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeRegistry.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeRegistry.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using KitX.Core.Contract.Workflow; using KitX.Core.Workflow.BlockScripting; using Serilog; @@ -10,6 +11,8 @@ namespace KitX.Core.Workflow.Blueprint; /// Unified registry for node type creation and metadata. /// Each node type is self-describing via GetDescriptor(), eliminating /// the need for external switch statements when adding new node types. +/// All builtin function nodes are created via CreateBuiltinFunctionNode(), +/// driven by IBuiltinFunctionDefinition. /// public class NodeRegistry : INodeRegistry { @@ -19,26 +22,17 @@ public class NodeRegistry : INodeRegistry public NodeRegistry() { - // Map each BlueprintNodeType enum value to its concrete class _typeMap = new Dictionary { [BlueprintNodeType.Entry] = typeof(EntryNode), [BlueprintNodeType.PluginTrigger] = typeof(PluginTriggerNode), - [BlueprintNodeType.Branch] = typeof(BranchNode), - [BlueprintNodeType.Loop] = typeof(LoopNode), - [BlueprintNodeType.Break] = typeof(BreakNode), [BlueprintNodeType.Const] = typeof(ConstNode), [BlueprintNodeType.Call] = typeof(CallNode), [BlueprintNodeType.CallHelper] = typeof(CallHelperNode), - [BlueprintNodeType.Get] = typeof(GetNode), - [BlueprintNodeType.Set] = typeof(SetNode), - [BlueprintNodeType.Print] = typeof(PrintNode), - [BlueprintNodeType.Pause] = typeof(PauseNode), [BlueprintNodeType.Variable] = typeof(VariableNode), [BlueprintNodeType.BuiltinFunction] = typeof(BuiltinFunctionNode), }; - // Pre-cache descriptors from each node type _descriptorCache = new Dictionary(); foreach (var kvp in _typeMap) { @@ -95,7 +89,6 @@ public BlueprintNode CreateBuiltinFunctionNode(string functionName) Name = def.DisplayName }; - // Build descriptor from the function definition var descriptor = new NodeDescriptor( def.NodeWidth, def.NodeHeight, def.InputPins, def.OutputPins, @@ -103,7 +96,6 @@ public BlueprintNode CreateBuiltinFunctionNode(string functionName) ); node.SetDescriptor(descriptor); - // Initialize pins from descriptor foreach (var pd in descriptor.InputPins) node.InputPins.Add(new BlueprintPin { Name = pd.Name, Direction = PinDirection.Input, Type = pd.Type }); foreach (var pd in descriptor.OutputPins) diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/BS2CFGConverter.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/BS2CFGConverter.cs index 6fbc071d..00b26299 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/BS2CFGConverter.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/BS2CFGConverter.cs @@ -101,89 +101,82 @@ private List FormatFlowControl(FlowControlStatement flowCtrl, stri { var result = new List(); - switch (flowCtrl.ControlType) - { - case FlowControlType.Branch: - { - var (condStmts, condPubVar) = ExpandCondition(flowCtrl.ConditionExpression, blockName, context); - result.AddRange(condStmts); - result.Add(new CFGStatement - { - BlockName = blockName, - Kind = CFGStatementKind.Branch, - FunctionName = Branch, - ConditionPubVar = condPubVar, - ConditionExpression = flowCtrl.ConditionExpression, - TrueBlockName = flowCtrl.TrueBlockName, - FalseBlockName = flowCtrl.FalseBlockName, - OriginalExpression = flowCtrl.SourceCode, - SourceLine = flowCtrl.LineNumber, - StatementId = !string.IsNullOrEmpty(flowCtrl.StatementId) ? flowCtrl.StatementId : Guid.NewGuid().ToString() - }); - } - break; + // Determine the function name from the source code or control type + var functionName = GetFunctionNameFromFlowControl(flowCtrl); + var kind = _functionRegistry?.Get(functionName)?.StatementKind ?? MapControlTypeToKind(flowCtrl.ControlType); - case FlowControlType.Loop: - { - var (condStmts, condPubVar) = ExpandCondition(flowCtrl.ConditionExpression, blockName, context); - result.AddRange(condStmts); + // Expand condition for Branch/Loop + var hasCondition = !string.IsNullOrEmpty(flowCtrl.ConditionExpression); + string? condPubVar = null; - var loopStmt = new CFGStatement - { - StatementId = !string.IsNullOrEmpty(flowCtrl.StatementId) ? flowCtrl.StatementId : Guid.NewGuid().ToString(), - BlockName = blockName, - Kind = CFGStatementKind.Loop, - FunctionName = Loop, - ConditionPubVar = condPubVar, - ConditionExpression = flowCtrl.ConditionExpression, - TrueBlockName = flowCtrl.TrueBlockName, - FalseBlockName = flowCtrl.FalseBlockName, - OriginalExpression = flowCtrl.SourceCode, - SourceLine = flowCtrl.LineNumber - }; - result.Add(loopStmt); - - // Store condition for duplication before ToLoopCond - if (!string.IsNullOrEmpty(condPubVar)) - { - context.LoopConditions[blockName] = new ConditionInfo - { - ConditionPubVar = condPubVar, - RawExpression = flowCtrl.ConditionExpression, - ExpansionStatements = condStmts.ToList() - }; - } - } - break; - - case FlowControlType.ToLoopCond: - result.Add(new CFGStatement - { - StatementId = !string.IsNullOrEmpty(flowCtrl.StatementId) ? flowCtrl.StatementId : Guid.NewGuid().ToString(), - BlockName = blockName, - Kind = CFGStatementKind.ToLoopCond, - FunctionName = ToLoopCond, - ToLoopCondReturnTo = flowCtrl.ToLoopCondReturnTo, - OriginalExpression = flowCtrl.SourceCode, - SourceLine = flowCtrl.LineNumber - }); - break; + if (hasCondition) + { + var (condStmts, pubVar) = ExpandCondition(flowCtrl.ConditionExpression, blockName, context); + result.AddRange(condStmts); + condPubVar = pubVar; + } - case FlowControlType.Break: - result.Add(new CFGStatement - { - StatementId = !string.IsNullOrEmpty(flowCtrl.StatementId) ? flowCtrl.StatementId : Guid.NewGuid().ToString(), - BlockName = blockName, - Kind = CFGStatementKind.Break, - FunctionName = Break, - OriginalExpression = flowCtrl.SourceCode, - SourceLine = flowCtrl.LineNumber - }); - break; + var stmt = new CFGStatement + { + StatementId = !string.IsNullOrEmpty(flowCtrl.StatementId) ? flowCtrl.StatementId : Guid.NewGuid().ToString(), + BlockName = blockName, + Kind = kind, + FunctionName = functionName, + ConditionPubVar = condPubVar, + ConditionExpression = flowCtrl.ConditionExpression, + TrueBlockName = flowCtrl.TrueBlockName, + FalseBlockName = flowCtrl.FalseBlockName, + ToLoopCondReturnTo = flowCtrl.ToLoopCondReturnTo, + OriginalExpression = flowCtrl.SourceCode, + SourceLine = flowCtrl.LineNumber + }; + result.Add(stmt); + + // Store loop condition for duplication before ToLoopCond + if (kind == CFGStatementKind.Loop && !string.IsNullOrEmpty(condPubVar)) + { + context.LoopConditions[blockName] = new ConditionInfo + { + ConditionPubVar = condPubVar, + RawExpression = flowCtrl.ConditionExpression, + ExpansionStatements = result.Where(s => s != stmt).ToList() + }; } + return result; } + private static string GetFunctionNameFromFlowControl(FlowControlStatement flowCtrl) + { + var src = flowCtrl.SourceCode; + // Parse function name from source like "NextBlock = Branch(...)", "Break()", etc. + var parsed = ExprUtils.ParseStatement(src); + if (parsed?.rightExpr is InvocationExpressionSyntax invoke) + return ExprUtils.GetMethodName(invoke); + if (parsed?.rightExpr is IdentifierNameSyntax id) + return id.Identifier.Text; + // Fallback: derive from ControlType + return MapControlTypeToFunctionName(flowCtrl.ControlType); + } + + private static string MapControlTypeToFunctionName(FlowControlType type) => type switch + { + FlowControlType.Branch => Branch, + FlowControlType.Loop => Loop, + FlowControlType.ToLoopCond => ToLoopCond, + FlowControlType.Break => Break, + _ => string.Empty + }; + + private static CFGStatementKind MapControlTypeToKind(FlowControlType type) => type switch + { + FlowControlType.Branch => CFGStatementKind.Branch, + FlowControlType.Loop => CFGStatementKind.Loop, + FlowControlType.ToLoopCond => CFGStatementKind.ToLoopCond, + FlowControlType.Break => CFGStatementKind.Break, + _ => CFGStatementKind.Unknown + }; + // ────────────────────────────────────────────── // Expression statement formatting // ────────────────────────────────────────────── diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/CFG2BPConverter.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/CFG2BPConverter.cs index f7bbe1d9..d1ba91ca 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/CFG2BPConverter.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/CFG2BPConverter.cs @@ -93,11 +93,7 @@ private void ProcessBlock(CFGBlock block, PipelineContext context) if (node != null) context.BlockNodeIds[block.Name].Add(node.Id); - endsWithFlowCtrl = stmt.Kind is CFGStatementKind.Branch - or CFGStatementKind.Loop - or CFGStatementKind.ToLoopCond - or CFGStatementKind.Break - || IsRegistryFlowControlTerminator(stmt); + endsWithFlowCtrl = IsRegistryFlowControlTerminator(stmt); } if (firstNode != null) @@ -119,58 +115,37 @@ or CFGStatementKind.Break private BlueprintNode? ProcessStatement(CFGStatement stmt, string blockName, PipelineContext context, ref BlueprintNode? prevNode, ref string? prevStmtId) { - // Registry path: handle all registered block terminators (Branch/Loop/ToLoopCond/Break/Flip) + // Registry path: handle all registered builtin functions if (_functionRegistry != null && !string.IsNullOrEmpty(stmt.FunctionName)) { var funcDef = _functionRegistry.Get(stmt.FunctionName); - if (funcDef != null && funcDef.IsBlockTerminator) + if (funcDef != null) { - // Use LegacyNodeType when available for backward compatibility - BlueprintNode node = funcDef.LegacyNodeType is { } legacyType - ? _registry.Create(legacyType) - : _registry.CreateBuiltinFunctionNode(stmt.FunctionName); - node = funcDef.ConfigureNode(node, stmt); - ChainNewNode(node, stmt, context, ref prevNode, ref prevStmtId); - funcDef.OnNodeCreated(node, stmt, context); - return node; + if (funcDef.IsBlockTerminator) + { + var node = _registry.CreateBuiltinFunctionNode(stmt.FunctionName); + node = funcDef.ConfigureNode(node, stmt); + ChainNewNode(node, stmt, context, ref prevNode, ref prevStmtId); + funcDef.OnNodeCreated(node, stmt, context); + return node; + } + + // Non-terminator registered function + var nonTermNode = _registry.CreateBuiltinFunctionNode(stmt.FunctionName); + nonTermNode = funcDef.ConfigureNode(nonTermNode, stmt); + var result = ChainNewNode(nonTermNode, stmt, context, ref prevNode, ref prevStmtId); + RegisterPubVarAssignment(stmt, nonTermNode, context); + return result; } } - // Non-terminator statement handling + // Non-registry statement handling switch (stmt.Kind) { case CFGStatementKind.Assignment: case CFGStatementKind.Expression: - return ProcessCallOrAssignment(stmt, context, ref prevNode, ref prevStmtId); - - case CFGStatementKind.Print: - case CFGStatementKind.Pause: - case CFGStatementKind.Set: case CFGStatementKind.PluginCallWithTarget: - { - // Use registry to determine the legacy node type and configure it - if (_functionRegistry != null && !string.IsNullOrEmpty(stmt.FunctionName) - && _functionRegistry.Get(stmt.FunctionName) is { } funcDef - && funcDef.LegacyNodeType is { } legacyType) - { - var node = _registry.Create(legacyType); - node = funcDef.ConfigureNode(node, stmt); - return ChainNewNode(node, stmt, context, ref prevNode, ref prevStmtId); - } - // Fallback without registry (should not happen in production) - var fallbackType = stmt.Kind switch - { - CFGStatementKind.Print => BlueprintNodeType.Print, - CFGStatementKind.Pause => BlueprintNodeType.Pause, - CFGStatementKind.Set => BlueprintNodeType.Set, - CFGStatementKind.PluginCallWithTarget => BlueprintNodeType.Call, - _ => BlueprintNodeType.Call - }; - var fallbackNode = _registry.Create(fallbackType); - if (fallbackType == BlueprintNodeType.Set && fallbackNode is SetNode sn) - sn.VarName = stmt.SetVarName ?? ""; - return ChainNewNode(fallbackNode, stmt, context, ref prevNode, ref prevStmtId); - } + return ProcessCallOrAssignment(stmt, context, ref prevNode, ref prevStmtId); default: return null; @@ -196,20 +171,18 @@ private bool IsRegistryFlowControlTerminator(CFGStatement stmt) PipelineContext context, ref BlueprintNode? prevNode, ref string? prevStmtId) { // --- PubVar reuse check (§6.5) --- - // For Get statements: only LOOP_COND_DUP reuses by PubVarTarget; others always create new + // For Get statements: reuse by PubVarTarget (each Get is unique based on its PubVar) // For non-Get statements: reuse by fingerprint PubVarAssignment? existing = null; - if (stmt.FunctionName == Get) + if (stmt.FunctionName == Get && stmt.Arguments?.Count > 0) { - // Get nodes: reuse when PubVarTarget matches and reads the same variable. - // This handles both LOOP_COND_DUP and round-trip re-parsed Get statements - // that assign to the same PubVar for the same variable. - if (!string.IsNullOrEmpty(stmt.PubVarTarget) && !string.IsNullOrEmpty(stmt.GetVarName)) + if (!string.IsNullOrEmpty(stmt.PubVarTarget)) { existing = context.PubVarAssignments.Values .FirstOrDefault(p => p.PubVarName == stmt.PubVarTarget - && p.SourceNode is GetNode gn && gn.VarName == stmt.GetVarName); + && p.SourceNode is BuiltinFunctionNode bfn && bfn.FunctionName == "Get" + && bfn.InputPins.FirstOrDefault(pin => pin.Name == "VarName")?.DefaultValue == stmt.Arguments[0].Trim('"')); } } else if (!string.IsNullOrEmpty(stmt.Fingerprint) @@ -235,11 +208,7 @@ private bool IsRegistryFlowControlTerminator(CFGStatement stmt) BlueprintNode mainNode; if (_functionRegistry != null && _functionRegistry.Get(stmt.FunctionName!) is { } funcDef) { - // Registered function (including Get) → create appropriate node type - if (funcDef.LegacyNodeType is { } legacyType) - mainNode = _registry.Create(legacyType); - else - mainNode = _registry.CreateBuiltinFunctionNode(stmt.FunctionName!); + mainNode = _registry.CreateBuiltinFunctionNode(stmt.FunctionName!); mainNode = funcDef.ConfigureNode(mainNode, stmt); } else @@ -292,25 +261,7 @@ private bool IsRegistryFlowControlTerminator(CFGStatement stmt) prevStmtId = stmt.StatementId; // Register PubVar assignment for reuse - if (!string.IsNullOrEmpty(stmt.PubVarTarget)) - { - var outputPinName = stmt.FunctionName == Get ? Value : Return; - var outputPin = mainNode.OutputPins.FirstOrDefault(p => p.Name == outputPinName) - ?? mainNode.OutputPins.FirstOrDefault(p => p.Type != PinType.Execution); - if (outputPin != null) - { - // Get: key by PubVarTarget (each Get is unique, identified by its PubVar) - // Others: key by fingerprint (for reuse detection) - var key = stmt.FunctionName == Get ? stmt.PubVarTarget : (stmt.Fingerprint ?? stmt.PubVarTarget); - context.PubVarAssignments[key] = new PubVarAssignment - { - PubVarName = stmt.PubVarTarget, - SourceNode = mainNode, - SourcePin = outputPin, - StatementId = stmt.StatementId, - }; - } - } + RegisterPubVarAssignment(stmt, mainNode, context); return mainNode; } @@ -401,6 +352,27 @@ private BlueprintNode ChainNewNode(BlueprintNode node, CFGStatement stmt, return node; } + private void RegisterPubVarAssignment(CFGStatement stmt, BlueprintNode node, PipelineContext context) + { + if (string.IsNullOrEmpty(stmt.PubVarTarget)) return; + + var outputPin = node.OutputPins.FirstOrDefault(p => p.Type != PinType.Execution); + if (outputPin == null) return; + + // Get: key by PubVarTarget (each Get is unique based on what variable it reads) + // Others: key by fingerprint for reuse detection + var key = stmt.FunctionName == "Get" + ? stmt.PubVarTarget + : (stmt.Fingerprint ?? stmt.PubVarTarget); + context.PubVarAssignments[key] = new PubVarAssignment + { + PubVarName = stmt.PubVarTarget, + SourceNode = node, + SourcePin = outputPin, + StatementId = stmt.StatementId, + }; + } + private void AddExecEdge(string? sourceStmtId, string targetStmtId, PipelineContext context) { if (sourceStmtId == null) return; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/DataEdgeBuilder.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/DataEdgeBuilder.cs index a55871db..7016867c 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/DataEdgeBuilder.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/DataEdgeBuilder.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using KitX.Core.Contract.Workflow; +using KitX.Core.Workflow.BlockScripting; using KitX.Core.Workflow.Blueprint.CFG; using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Pins; @@ -12,12 +13,19 @@ namespace KitX.Core.Workflow.Blueprint.Pipeline; /// /// Phase 4+5: Creates data edges from ControlFlowGraph argument analysis. /// Handles PubVar references, ConstBlock connections, DefaultValues, and data edge dedup. +/// All argument-to-pin mapping is driven by IBuiltinFunctionDefinition.InputPins. /// public class DataEdgeBuilder { + private readonly BuiltinFunctionRegistry? _functionRegistry; + + public DataEdgeBuilder(BuiltinFunctionRegistry? functionRegistry = null) + { + _functionRegistry = functionRegistry; + } + public void Build(PipelineContext context) { - // Process all statements in all blocks foreach (var block in context.FormattedScript.Blocks) { foreach (var stmt in block.Statements) @@ -26,7 +34,6 @@ public void Build(PipelineContext context) } } - // Deduplicate data edges (Phase 5) DeduplicateDataEdges(context); Log.Debug("[DataEdgeBuilder] Done: {DataEdgeCount} data edges", @@ -34,56 +41,55 @@ public void Build(PipelineContext context) } // ────────────────────────────────────────────── - // Statement processing + // Statement processing — registry-driven // ────────────────────────────────────────────── private void ProcessStatement(CFGStatement stmt, PipelineContext context) { - switch (stmt.Kind) - { - case CFGStatementKind.Assignment: - case CFGStatementKind.Expression: - ProcessCallArguments(stmt, context); - break; - - case CFGStatementKind.Print: - ProcessSingleValueInput(stmt, stmt.Arguments, Value, context); - break; - - case CFGStatementKind.Set: - ProcessSetValue(stmt, context); - break; - - case CFGStatementKind.Pause: - ProcessSingleValueInput(stmt, stmt.Arguments, "Milliseconds", context); - break; - - case CFGStatementKind.Branch: - ProcessConditionInput(stmt, context); - break; - - case CFGStatementKind.Loop: - ProcessConditionInput(stmt, context); - break; - } - } + if (!context.NodeByStatementId.TryGetValue(stmt.StatementId, out var targetNode)) return; - // ────────────────────────────────────────────── - // Call/Assignment argument processing - // ────────────────────────────────────────────── + // Use registry to determine pin mapping + var funcDef = _functionRegistry?.Get(stmt.FunctionName ?? string.Empty); - private void ProcessCallArguments(CFGStatement stmt, PipelineContext context) - { - if (!context.NodeByStatementId.TryGetValue(stmt.StatementId, out var targetNode)) return; - if (stmt.Arguments == null) return; + if (funcDef != null) + { + // Map arguments to input pins based on the function definition + var nonExecPins = funcDef.InputPins.Where(p => p.Type != PinType.Execution).ToList(); + + if (stmt.Arguments != null) + { + for (int i = 0; i < stmt.Arguments.Count && i < nonExecPins.Count; i++) + { + ProcessArgument(stmt.Arguments[i], targetNode, nonExecPins[i].Name, stmt, i, context); + } + } - for (int i = 0; i < stmt.Arguments.Count; i++) + // Connect ConditionPubVar to any Boolean-type input pin (Branch/Loop condition) + if (!string.IsNullOrEmpty(stmt.ConditionPubVar)) + { + var condPin = funcDef.InputPins.FirstOrDefault(p => p.Type == PinType.Boolean); + if (condPin != null) + ConnectPubVarSource(stmt.ConditionPubVar, targetNode, condPin.Name, context); + } + } + else { - var arg = stmt.Arguments[i]; - var pinName = GetParamPinName(targetNode, i); - if (pinName == null) continue; + // Non-registry statements: generic argument processing + if (stmt.Arguments != null) + { + for (int i = 0; i < stmt.Arguments.Count; i++) + { + var pinName = GetParamPinName(targetNode, i); + if (pinName == null) continue; + ProcessArgument(stmt.Arguments[i], targetNode, pinName, stmt, i, context); + } + } - ProcessArgument(arg, targetNode, pinName, stmt, i, context); + // Handle condition for non-registry Branch/Loop + if (!string.IsNullOrEmpty(stmt.ConditionPubVar)) + { + ConnectPubVarSource(stmt.ConditionPubVar, targetNode, Condition, context); + } } } @@ -103,7 +109,7 @@ private void ProcessArgument(string arg, BlueprintNode targetNode, string target return; } - // Character literal → DefaultValue (pass through as-is, e.g. '\0') + // Character literal → DefaultValue if (ExprUtils.IsCharacterLiteral(trimmed)) { SetDefaultValue(targetNode, targetPinName, trimmed); @@ -138,53 +144,17 @@ private void ProcessArgument(string arg, BlueprintNode targetNode, string target } // VariableNode (no initial value) → set as DefaultValue fallback on target pin - // VariableNodes have no output ports, so we can't create a data edge. - // The variable reference is resolved at runtime. if (context.VariableNodes.ContainsKey(trimmed)) { SetDefaultValue(targetNode, targetPinName, trimmed); return; } - // Unknown identifier → try as variable reference (set DefaultValue as fallback) + // Unknown identifier → try as variable reference Log.Warning("[DataEdgeBuilder] Unresolved argument: {Arg} in stmt {StmtId}", trimmed, parentStmt.StatementId); SetDefaultValue(targetNode, targetPinName, trimmed); } - // ────────────────────────────────────────────── - // Special node argument processing - // ────────────────────────────────────────────── - - private void ProcessSingleValueInput(CFGStatement stmt, List? args, - string pinName, PipelineContext context) - { - if (args == null || args.Count == 0) return; - if (!context.NodeByStatementId.TryGetValue(stmt.StatementId, out var targetNode)) return; - - ProcessArgument(args[0], targetNode, pinName, stmt, 0, context); - } - - private void ProcessSetValue(CFGStatement stmt, PipelineContext context) - { - if (!context.NodeByStatementId.TryGetValue(stmt.StatementId, out var targetNode)) return; - if (stmt.Arguments == null || stmt.Arguments.Count == 0) return; - - // Set's Value pin receives the second argument (first was extracted as SetVarName) - if (stmt.Arguments.Count > 0) - { - ProcessArgument(stmt.Arguments[0], targetNode, Value, stmt, 0, context); - } - } - - private void ProcessConditionInput(CFGStatement stmt, PipelineContext context) - { - if (string.IsNullOrEmpty(stmt.ConditionPubVar)) return; - if (!context.NodeByStatementId.TryGetValue(stmt.StatementId, out var targetNode)) return; - - // Condition PubVar → find the PubVarAssignment source and connect to Condition pin - ConnectPubVarSource(stmt.ConditionPubVar, targetNode, Condition, context); - } - // ────────────────────────────────────────────── // PubVar source connection // ────────────────────────────────────────────── @@ -192,7 +162,6 @@ private void ProcessConditionInput(CFGStatement stmt, PipelineContext context) private void ConnectPubVarSource(string pubVarName, BlueprintNode targetNode, string targetPinName, PipelineContext context) { - // Find the PubVarAssignment that produces this PubVar foreach (var kvp in context.PubVarAssignments) { if (kvp.Value.PubVarName == pubVarName) @@ -222,7 +191,6 @@ private void DeduplicateDataEdges(PipelineContext context) foreach (var edge in context.DataEdges) { - // Key by (source node, source pin, target node, target pin, pubvar) var key = $"{edge.SourceNodeId}|{edge.SourcePinName}|{edge.TargetNodeId}|{edge.TargetPinName}|{edge.PubVarName ?? ""}"; if (!unique.ContainsKey(key)) unique[key] = edge; @@ -250,11 +218,9 @@ private static void SetDefaultValue(BlueprintNode node, string pinName, string v private static string? GetParamPinName(BlueprintNode node, int argIndex) { - // Find parameter pins (skip Exec pin at index 0) var paramPins = node.InputPins.Where(p => p.Name != Exec).ToList(); if (argIndex < paramPins.Count) return paramPins[argIndex].Name; return null; } - } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineContext.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineContext.cs index ec100163..76759489 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineContext.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineContext.cs @@ -36,8 +36,7 @@ public class PipelineContext public Dictionary> BlockNodeIds { get; set; } = new(); public Dictionary BlockNextBlock { get; set; } = new(); public Dictionary BlockEndsWithFlowCtrl { get; set; } = new(); - public List<(string stmtId, string? trueBlock, string? falseBlock)> BranchDefs { get; set; } = new(); - public List<(string stmtId, string? loopBody, string? loopEnd, string parentBlock)> LoopDefs { get; set; } = new(); + // --- Phase 4 output --- public List DataEdges { get; set; } = new(); @@ -153,7 +152,6 @@ public class PendingDataEdge /// /// Generic deferred control flow edge for IBuiltinFunctionDefinition-based functions. -/// Replaces per-function BranchDefs/LoopDefs with a unified structure. /// Populated by OnNodeCreated, resolved by CFG2BPConverter.ResolveCrossBlockEdges. /// public struct DeferredControlFlowEdge diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowStorageService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowStorageService.cs index d04ba07e..c5310855 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowStorageService.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowStorageService.cs @@ -261,9 +261,9 @@ private void EnsureDirectoryExists() { return JsonSerializer.Deserialize(json, _jsonOptions); } - catch (NotSupportedException ex) when (ex.Message.Contains("type discriminator")) + catch (JsonException ex) when (ex.Message.Contains("type discriminator")) { - // BlueprintData has nodes without $type discriminators — retry without BP data + // BlueprintData has nodes with unrecognized $type discriminators — retry without BP data Log.Warning(ex, "[WorkflowStorageService] BlueprintData deserialization failed for {FilePath}. " + "Retrying without BlueprintData (legacy format or missing type discriminators)", diff --git a/KitX Clients/KitX Dashboard b/KitX Clients/KitX Dashboard index 2c4135a5..168eb05b 160000 --- a/KitX Clients/KitX Dashboard +++ b/KitX Clients/KitX Dashboard @@ -1 +1 @@ -Subproject commit 2c4135a52cef9f53c41b89fe49d3282d274584fd +Subproject commit 168eb05bf6bbe72cc5cdb118bc162f7808a231b6 diff --git a/KitX Standard b/KitX Standard index 8d1243f4..51da1348 160000 --- a/KitX Standard +++ b/KitX Standard @@ -1 +1 @@ -Subproject commit 8d1243f4ade2eaf3e6a59f5624702c43191579d5 +Subproject commit 51da1348a5cc9455adb94dd38a684ba41bdf63e2 From 7aa6721f7bd836e39f8a7f0382303ad8e05f5686 Mon Sep 17 00:00:00 2001 From: StarInk Date: Mon, 18 May 2026 05:36:22 +0200 Subject: [PATCH 072/110] =?UTF-8?q?=F0=9F=A7=A9=20Refactor(Workflow):=20?= =?UTF-8?q?=E7=A7=BB=E5=8A=A8=E6=96=87=E4=BB=B6=E5=A4=B9=E5=8F=8A=E5=91=BD?= =?UTF-8?q?=E5=90=8D=E7=A9=BA=E9=97=B4=E3=80=81=E5=88=A0=E9=99=A4=E6=9C=AA?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=E7=9A=84=E5=BC=95=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../KitX.Core/Activity/ActivityManager.cs | 8 +------- .../Announcement/AnnouncementManager.cs | 10 +--------- .../Configuration/AnnouncementConfig.cs | 3 +-- .../KitX.Core/Configuration/AppConfig.cs | 5 +---- .../KitX.Core/Configuration/ConfigLoader.cs | 5 +---- .../KitX.Core/Configuration/ConfigManager.cs | 5 +---- .../KitX.Core/Configuration/ConfigSaver.cs | 4 +--- .../KitX.Core/Configuration/Config_App.cs | 3 +-- .../KitX.Core/Configuration/Config_Web.cs | 3 +-- .../KitX.Core/Configuration/Config_Windows.cs | 3 +-- .../KitX.Core/Configuration/DeviceKeyImpl.cs | 3 +-- .../KitX.Core/Configuration/PluginsConfig.cs | 6 +----- .../KitX.Core/Configuration/SecurityConfig.cs | 5 +---- .../KitX Core/KitX.Core/ConstantTable.cs | 3 +-- .../KitX Core/KitX.Core/Device/DeviceCase.cs | 6 +----- .../KitX.Core/Device/DeviceHttpClient.cs | 6 +----- .../KitX.Core/Device/DevicesDiscoveryServer.cs | 10 +--------- .../KitX.Core/Device/DevicesOrganizer.cs | 7 +------ .../KitX Core/KitX.Core/Device/DevicesServer.cs | 10 +--------- .../KitX.Core/Device/Events/PluginEventArgs.cs | 5 +---- .../KitX Core/KitX.Core/Device/NetworkHelper.cs | 5 +---- .../KitX.Core/Device/OperatingSystemHelper.cs | 3 +-- .../KitX.Core/Device/PluginConnection.cs | 6 +----- .../KitX Core/KitX.Core/Device/PluginsServer.cs | 9 +-------- .../KitX Core/KitX.Core/Event/EventArgs.cs | 4 +--- .../KitX Core/KitX.Core/Event/EventService.cs | 6 +----- .../KitX.Core/FileWatcher/FileWatcherManager.cs | 5 +---- .../KitX Core/KitX.Core/Hotkey/KeyHookManager.cs | 6 +----- .../KitX.Core/Plugin/PluginInstallation.cs | 4 +--- .../KitX Core/KitX.Core/Plugin/PluginsManager.cs | 8 +------- .../KitX.Core/Security/SecurityManager.cs | 7 +------ .../KitX.Core/Statistics/StatisticsManager.cs | 5 +---- .../KitX Core/KitX.Core/Tasks/TasksManager.cs | 5 +---- .../KitX.Core/Workflow/BlockScriptServiceImpl.cs | 9 +-------- .../Workflow/BlockScripting/BlockScopeManager.cs | 5 +---- .../BlockScriptExecutionGlobals.cs | 6 +----- .../BlockScripting/BlockScriptExecutor.cs | 11 ++--------- .../Workflow/BlockScripting/BlockScriptParser.cs | 5 +---- .../BlockScripting/BlockStatementExtractor.cs | 7 ++----- .../BlockScripting/BlockStructureRecognizer.cs | 4 +--- .../BlockScripting/BlockSyntaxValidator.cs | 4 +--- .../Workflow/BlockScripting/BlueprintDebugger.cs | 5 +---- .../BlockScripting/BuiltinFunctionRegistry.cs | 6 +----- .../Workflow/BlockScripting/CFG2CSGenerator.cs | 13 +++---------- .../Workflow/BlockScripting/CSCompiler.cs | 8 +++----- .../BlockScripting/CompiledScriptEntry.cs | 5 +---- .../BlockScripting/CompiledScriptMeta.cs | 4 +--- .../HelperFunctionCodeGenerator.cs | 3 +-- .../BlockScripting/IBuiltinFunctionDefinition.cs | 8 +++----- .../BlockScripting/ICompiledBlockScript.cs | 6 +----- .../BlockScripting/ScriptCompilationBackend.cs | 6 +----- .../BlockScripting/ScriptPersistenceManager.cs | 5 +---- .../Blueprint/BlockScriptToBlueprintConverter.cs | 9 +++------ .../Blueprint/BlueprintRenderDataService.cs | 4 +--- .../Workflow/Blueprint/BlueprintService.cs | 6 +----- .../Blueprint/BlueprintToBlockScriptConverter.cs | 6 ++---- .../BuiltinFunctionExportStrategyAdapter.cs | 5 ++--- .../Workflow/Blueprint/ConversionContext.cs | 3 +-- .../Workflow/Blueprint/LayoutService.cs | 5 +---- .../Workflow/Blueprint/NodeExportHelper.cs | 7 ++----- .../KitX.Core/Workflow/Blueprint/NodeRegistry.cs | 5 +---- .../BuiltinFunctions/BranchFunction.cs | 13 +++++-------- .../BuiltinFunctions/BreakFunction.cs | 11 +++++------ .../BuiltinFunctions/CreateWorkflowFunction.cs | 13 +++++-------- .../BuiltinFunctions/FlipFunction.cs | 11 +++++------ .../BuiltinFunctions/GetFunction.cs | 12 +++++------- .../GetPluginInfoByNameFunction.cs | 13 +++++-------- .../BuiltinFunctions/InstallPluginFunction.cs | 13 +++++-------- .../BuiltinFunctions/JsonGetFieldFunction.cs | 12 +++++------- .../BuiltinFunctions/ListPluginNamesFunction.cs | 13 +++++-------- .../BuiltinFunctions/ListWorkflowsFunction.cs | 13 +++++-------- .../BuiltinFunctions/LoopFunction.cs | 13 +++++-------- .../BuiltinFunctions/PauseFunction.cs | 12 +++++------- .../BuiltinFunctions/PluginCallFunction.cs | 14 +++++--------- .../PluginCallWithTargetFunction.cs | 15 +++++---------- .../BuiltinFunctions/PrintFunction.cs | 11 +++++------ .../BuiltinFunctions/ReadTextFileFunction.cs | 12 +++++------- .../BuiltinFunctions/RunWorkflowFunction.cs | 13 +++++-------- .../BuiltinFunctions/SetFunction.cs | 12 +++++------- .../BuiltinFunctions/StartPluginFunction.cs | 13 +++++-------- .../BuiltinFunctions/StopPluginFunction.cs | 13 +++++-------- .../BuiltinFunctions/StopWorkflowFunction.cs | 13 +++++-------- .../BuiltinFunctions/ToLoopCondFunction.cs | 11 +++++------ .../BuiltinFunctions/TryGetDeviceFunction.cs | 16 +++++----------- .../BuiltinFunctions/WriteTextFileFunction.cs | 12 +++++------- .../{Blueprint => }/CFG/BP2CFGConverter.cs | 11 ++++------- .../{Blueprint => }/CFG/BlockScriptSerializer.cs | 6 ++---- .../{Blueprint => }/CFG/BlueprintDebugContext.cs | 4 +--- .../{Blueprint => }/CFG/CFG2BSConverter.cs | 6 ++---- .../Workflow/{Blueprint => }/CFG/CFGBlock.cs | 2 +- .../{Blueprint => }/CFG/CFGBuilderFromScript.cs | 10 ++++------ .../CFG/CFGConditionDuplicator.cs | 8 +------- .../Workflow/{Blueprint => }/CFG/CFGEdge.cs | 2 +- .../Workflow/{Blueprint => }/CFG/CFGPipeline.cs | 7 ++++--- .../Workflow/{Blueprint => }/CFG/CFGStatement.cs | 2 +- .../{Blueprint => }/CFG/ConstDeclaration.cs | 2 +- .../{Blueprint => }/CFG/ControlFlowGraph.cs | 4 ++-- .../KitX.Core/Workflow/ConstantValueRewriter.cs | 4 +--- .../KitX.Core/Workflow/KcsFileService.cs | 5 +---- .../{Blueprint => }/Pipeline/BS2CFGConverter.cs | 10 +++------- .../{Blueprint => }/Pipeline/CFG2BPConverter.cs | 9 +++------ .../{Blueprint => }/Pipeline/DataEdgeBuilder.cs | 9 +++------ .../{Blueprint => }/Pipeline/ExprUtils.cs | 10 ++-------- .../Pipeline/PipelineAssembler.cs | 9 ++------- .../{Blueprint => }/Pipeline/PipelineContext.cs | 9 +++------ .../KitX.Core/Workflow/RealPluginManager.cs | 8 +------- .../KitX.Core/Workflow/TriggerManager.cs | 9 +-------- .../KitX Core/KitX.Core/Workflow/WorkflowCase.cs | 3 +-- .../Workflow/WorkflowManagementService.cs | 8 +------- .../KitX.Core/Workflow/WorkflowOutput.cs | 3 +-- .../KitX.Core/Workflow/WorkflowPluginService.cs | 6 +----- .../KitX.Core/Workflow/WorkflowRuntimeState.cs | 3 +-- .../KitX.Core/Workflow/WorkflowScriptService.cs | 7 +------ .../KitX.Core/Workflow/WorkflowStorageService.cs | 7 +------ 114 files changed, 245 insertions(+), 595 deletions(-) rename KitX Clients/KitX Core/KitX.Core/Workflow/{BlockScripting => }/BuiltinFunctions/BranchFunction.cs (92%) rename KitX Clients/KitX Core/KitX.Core/Workflow/{BlockScripting => }/BuiltinFunctions/BreakFunction.cs (86%) rename KitX Clients/KitX Core/KitX.Core/Workflow/{BlockScripting => }/BuiltinFunctions/CreateWorkflowFunction.cs (92%) rename KitX Clients/KitX Core/KitX.Core/Workflow/{BlockScripting => }/BuiltinFunctions/FlipFunction.cs (95%) rename KitX Clients/KitX Core/KitX.Core/Workflow/{BlockScripting => }/BuiltinFunctions/GetFunction.cs (93%) rename KitX Clients/KitX Core/KitX.Core/Workflow/{BlockScripting => }/BuiltinFunctions/GetPluginInfoByNameFunction.cs (93%) rename KitX Clients/KitX Core/KitX.Core/Workflow/{BlockScripting => }/BuiltinFunctions/InstallPluginFunction.cs (90%) rename KitX Clients/KitX Core/KitX.Core/Workflow/{BlockScripting => }/BuiltinFunctions/JsonGetFieldFunction.cs (95%) rename KitX Clients/KitX Core/KitX.Core/Workflow/{BlockScripting => }/BuiltinFunctions/ListPluginNamesFunction.cs (91%) rename KitX Clients/KitX Core/KitX.Core/Workflow/{BlockScripting => }/BuiltinFunctions/ListWorkflowsFunction.cs (91%) rename KitX Clients/KitX Core/KitX.Core/Workflow/{BlockScripting => }/BuiltinFunctions/LoopFunction.cs (92%) rename KitX Clients/KitX Core/KitX.Core/Workflow/{BlockScripting => }/BuiltinFunctions/PauseFunction.cs (89%) rename KitX Clients/KitX Core/KitX.Core/Workflow/{BlockScripting => }/BuiltinFunctions/PluginCallFunction.cs (91%) rename KitX Clients/KitX Core/KitX.Core/Workflow/{BlockScripting => }/BuiltinFunctions/PluginCallWithTargetFunction.cs (96%) rename KitX Clients/KitX Core/KitX.Core/Workflow/{BlockScripting => }/BuiltinFunctions/PrintFunction.cs (90%) rename KitX Clients/KitX Core/KitX.Core/Workflow/{BlockScripting => }/BuiltinFunctions/ReadTextFileFunction.cs (90%) rename KitX Clients/KitX Core/KitX.Core/Workflow/{BlockScripting => }/BuiltinFunctions/RunWorkflowFunction.cs (90%) rename KitX Clients/KitX Core/KitX.Core/Workflow/{BlockScripting => }/BuiltinFunctions/SetFunction.cs (92%) rename KitX Clients/KitX Core/KitX.Core/Workflow/{BlockScripting => }/BuiltinFunctions/StartPluginFunction.cs (91%) rename KitX Clients/KitX Core/KitX.Core/Workflow/{BlockScripting => }/BuiltinFunctions/StopPluginFunction.cs (91%) rename KitX Clients/KitX Core/KitX.Core/Workflow/{BlockScripting => }/BuiltinFunctions/StopWorkflowFunction.cs (90%) rename KitX Clients/KitX Core/KitX.Core/Workflow/{BlockScripting => }/BuiltinFunctions/ToLoopCondFunction.cs (95%) rename KitX Clients/KitX Core/KitX.Core/Workflow/{BlockScripting => }/BuiltinFunctions/TryGetDeviceFunction.cs (93%) rename KitX Clients/KitX Core/KitX.Core/Workflow/{BlockScripting => }/BuiltinFunctions/WriteTextFileFunction.cs (91%) rename KitX Clients/KitX Core/KitX.Core/Workflow/{Blueprint => }/CFG/BP2CFGConverter.cs (99%) rename KitX Clients/KitX Core/KitX.Core/Workflow/{Blueprint => }/CFG/BlockScriptSerializer.cs (98%) rename KitX Clients/KitX Core/KitX.Core/Workflow/{Blueprint => }/CFG/BlueprintDebugContext.cs (59%) rename KitX Clients/KitX Core/KitX.Core/Workflow/{Blueprint => }/CFG/CFG2BSConverter.cs (98%) rename KitX Clients/KitX Core/KitX.Core/Workflow/{Blueprint => }/CFG/CFGBlock.cs (98%) rename KitX Clients/KitX Core/KitX.Core/Workflow/{Blueprint => }/CFG/CFGBuilderFromScript.cs (98%) rename KitX Clients/KitX Core/KitX.Core/Workflow/{Blueprint => }/CFG/CFGConditionDuplicator.cs (94%) rename KitX Clients/KitX Core/KitX.Core/Workflow/{Blueprint => }/CFG/CFGEdge.cs (98%) rename KitX Clients/KitX Core/KitX.Core/Workflow/{Blueprint => }/CFG/CFGPipeline.cs (96%) rename KitX Clients/KitX Core/KitX.Core/Workflow/{Blueprint => }/CFG/CFGStatement.cs (99%) rename KitX Clients/KitX Core/KitX.Core/Workflow/{Blueprint => }/CFG/ConstDeclaration.cs (97%) rename KitX Clients/KitX Core/KitX.Core/Workflow/{Blueprint => }/CFG/ControlFlowGraph.cs (97%) rename KitX Clients/KitX Core/KitX.Core/Workflow/{Blueprint => }/Pipeline/BS2CFGConverter.cs (98%) rename KitX Clients/KitX Core/KitX.Core/Workflow/{Blueprint => }/Pipeline/CFG2BPConverter.cs (98%) rename KitX Clients/KitX Core/KitX.Core/Workflow/{Blueprint => }/Pipeline/DataEdgeBuilder.cs (97%) rename KitX Clients/KitX Core/KitX.Core/Workflow/{Blueprint => }/Pipeline/ExprUtils.cs (96%) rename KitX Clients/KitX Core/KitX.Core/Workflow/{Blueprint => }/Pipeline/PipelineAssembler.cs (97%) rename KitX Clients/KitX Core/KitX.Core/Workflow/{Blueprint => }/Pipeline/PipelineContext.cs (96%) diff --git a/KitX Clients/KitX Core/KitX.Core/Activity/ActivityManager.cs b/KitX Clients/KitX Core/KitX.Core/Activity/ActivityManager.cs index e2f22d38..bd20f422 100644 --- a/KitX Clients/KitX Core/KitX.Core/Activity/ActivityManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/Activity/ActivityManager.cs @@ -1,15 +1,9 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Linq.Expressions; -using Common.Activity; +using System.Linq.Expressions; using CActivity = Common.Activity.Activity; using Common.BasicHelper.Utils.Extensions; using KitX.Core.Contract.Activity; -using KitX.Core.Event; using LiteDB; using KitX.Core.Tasks; -using Serilog; namespace KitX.Core.Activity; diff --git a/KitX Clients/KitX Core/KitX.Core/Announcement/AnnouncementManager.cs b/KitX Clients/KitX Core/KitX.Core/Announcement/AnnouncementManager.cs index e27c26bc..02041be4 100644 --- a/KitX Clients/KitX Core/KitX.Core/Announcement/AnnouncementManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/Announcement/AnnouncementManager.cs @@ -1,16 +1,8 @@ -using KitX.Core.Configuration; +using KitX.Core.Configuration; using KitX.Core.Contract.Announcement; using KitX.Core.Contract.Configuration; -using Microsoft.AspNetCore.Components; using Serilog; -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Net.Http; -using System.Text; using System.Text.Json; -using System.Threading.Tasks; namespace KitX.Core.Announcement; diff --git a/KitX Clients/KitX Core/KitX.Core/Configuration/AnnouncementConfig.cs b/KitX Clients/KitX Core/KitX.Core/Configuration/AnnouncementConfig.cs index 87781b27..c7a7ff17 100644 --- a/KitX Clients/KitX Core/KitX.Core/Configuration/AnnouncementConfig.cs +++ b/KitX Clients/KitX Core/KitX.Core/Configuration/AnnouncementConfig.cs @@ -1,5 +1,4 @@ -using System.Collections.Generic; -using KitX.Core.Contract.Configuration; +using KitX.Core.Contract.Configuration; namespace KitX.Core.Configuration; diff --git a/KitX Clients/KitX Core/KitX.Core/Configuration/AppConfig.cs b/KitX Clients/KitX Core/KitX.Core/Configuration/AppConfig.cs index e7eedd8d..8190ded1 100644 --- a/KitX Clients/KitX Core/KitX.Core/Configuration/AppConfig.cs +++ b/KitX Clients/KitX Core/KitX.Core/Configuration/AppConfig.cs @@ -1,7 +1,4 @@ -using System; -using Common.BasicHelper.Graphics.Screen; -using KitX.Core.Contract.Configuration; -using Serilog.Events; +using KitX.Core.Contract.Configuration; namespace KitX.Core.Configuration; diff --git a/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigLoader.cs b/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigLoader.cs index 73ed4a26..ae90099a 100644 --- a/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigLoader.cs +++ b/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigLoader.cs @@ -1,7 +1,4 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Text.Json; +using System.Text.Json; using KitX.Core.Contract.Configuration; using KitX.Shared.CSharp.Device; using Serilog; diff --git a/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigManager.cs b/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigManager.cs index 17904d09..9e7a4ff7 100644 --- a/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigManager.cs @@ -1,7 +1,4 @@ -using System; -using System.Collections.Generic; -using System.IO; -using KitX.Core.Contract.Configuration; +using KitX.Core.Contract.Configuration; using Serilog; namespace KitX.Core.Configuration; diff --git a/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigSaver.cs b/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigSaver.cs index 0778f200..aad85d50 100644 --- a/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigSaver.cs +++ b/KitX Clients/KitX Core/KitX.Core/Configuration/ConfigSaver.cs @@ -1,6 +1,4 @@ -using System; -using System.IO; -using System.Text.Json; +using System.Text.Json; using KitX.Core.Contract.Configuration; using Serilog; diff --git a/KitX Clients/KitX Core/KitX.Core/Configuration/Config_App.cs b/KitX Clients/KitX Core/KitX.Core/Configuration/Config_App.cs index f1d76f3c..34bd6611 100644 --- a/KitX Clients/KitX Core/KitX.Core/Configuration/Config_App.cs +++ b/KitX Clients/KitX Core/KitX.Core/Configuration/Config_App.cs @@ -1,5 +1,4 @@ -using System.Collections.Generic; -using KitX.Core.Contract.Configuration; +using KitX.Core.Contract.Configuration; namespace KitX.Core.Configuration; diff --git a/KitX Clients/KitX Core/KitX.Core/Configuration/Config_Web.cs b/KitX Clients/KitX Core/KitX.Core/Configuration/Config_Web.cs index c1dd03b4..04c49f9a 100644 --- a/KitX Clients/KitX Core/KitX.Core/Configuration/Config_Web.cs +++ b/KitX Clients/KitX Core/KitX.Core/Configuration/Config_Web.cs @@ -1,5 +1,4 @@ -using System.Collections.Generic; -using KitX.Core.Contract.Configuration; +using KitX.Core.Contract.Configuration; namespace KitX.Core.Configuration; diff --git a/KitX Clients/KitX Core/KitX.Core/Configuration/Config_Windows.cs b/KitX Clients/KitX Core/KitX.Core/Configuration/Config_Windows.cs index 73d1f65f..94ee9f2d 100644 --- a/KitX Clients/KitX Core/KitX.Core/Configuration/Config_Windows.cs +++ b/KitX Clients/KitX Core/KitX.Core/Configuration/Config_Windows.cs @@ -1,5 +1,4 @@ -using Common.BasicHelper.Graphics.Screen; -using KitX.Core.Contract.Configuration; +using KitX.Core.Contract.Configuration; namespace KitX.Core.Configuration; diff --git a/KitX Clients/KitX Core/KitX.Core/Configuration/DeviceKeyImpl.cs b/KitX Clients/KitX Core/KitX.Core/Configuration/DeviceKeyImpl.cs index ec08d922..5c86b9e0 100644 --- a/KitX Clients/KitX Core/KitX.Core/Configuration/DeviceKeyImpl.cs +++ b/KitX Clients/KitX Core/KitX.Core/Configuration/DeviceKeyImpl.cs @@ -1,5 +1,4 @@ -using System; -using System.Text.Json.Serialization; +using System.Text.Json.Serialization; using KitX.Core.Contract.Configuration; using KitX.Shared.CSharp.Device; diff --git a/KitX Clients/KitX Core/KitX.Core/Configuration/PluginsConfig.cs b/KitX Clients/KitX Core/KitX.Core/Configuration/PluginsConfig.cs index 4ef8aa0a..73d175e7 100644 --- a/KitX Clients/KitX Core/KitX.Core/Configuration/PluginsConfig.cs +++ b/KitX Clients/KitX Core/KitX.Core/Configuration/PluginsConfig.cs @@ -1,10 +1,6 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text.Json.Serialization; +using System.Text.Json.Serialization; using KitX.Core.Contract.Configuration; using KitX.Core.Plugin; -using KitX.Shared.CSharp.Plugin; namespace KitX.Core.Configuration; diff --git a/KitX Clients/KitX Core/KitX.Core/Configuration/SecurityConfig.cs b/KitX Clients/KitX Core/KitX.Core/Configuration/SecurityConfig.cs index 03eda939..20b546fa 100644 --- a/KitX Clients/KitX Core/KitX.Core/Configuration/SecurityConfig.cs +++ b/KitX Clients/KitX Core/KitX.Core/Configuration/SecurityConfig.cs @@ -1,7 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text.Json.Serialization; +using System.Text.Json.Serialization; using KitX.Core.Contract.Configuration; namespace KitX.Core.Configuration; diff --git a/KitX Clients/KitX Core/KitX.Core/ConstantTable.cs b/KitX Clients/KitX Core/KitX.Core/ConstantTable.cs index b0a4d7ad..aae7965b 100644 --- a/KitX Clients/KitX Core/KitX.Core/ConstantTable.cs +++ b/KitX Clients/KitX Core/KitX.Core/ConstantTable.cs @@ -1,5 +1,4 @@ -using System; -using Common.BasicHelper.Utils.Extensions; +using Common.BasicHelper.Utils.Extensions; namespace KitX.Core; diff --git a/KitX Clients/KitX Core/KitX.Core/Device/DeviceCase.cs b/KitX Clients/KitX Core/KitX.Core/Device/DeviceCase.cs index d7447b53..3ee277ed 100644 --- a/KitX Clients/KitX Core/KitX.Core/Device/DeviceCase.cs +++ b/KitX Clients/KitX Core/KitX.Core/Device/DeviceCase.cs @@ -1,11 +1,7 @@ -using KitX.Core.Configuration; -using System; -using KitX.Core.Contract.Configuration; +using KitX.Core.Contract.Configuration; using KitX.Core.Contract.Device; using KitX.Core.Contract.Security; -using KitX.Core.Security; using KitX.Shared.CSharp.Device; -using Microsoft.Extensions.DependencyInjection; namespace KitX.Core.Device; diff --git a/KitX Clients/KitX Core/KitX.Core/Device/DeviceHttpClient.cs b/KitX Clients/KitX Core/KitX.Core/Device/DeviceHttpClient.cs index 8368bc8e..23e0801e 100644 --- a/KitX Clients/KitX Core/KitX.Core/Device/DeviceHttpClient.cs +++ b/KitX Clients/KitX Core/KitX.Core/Device/DeviceHttpClient.cs @@ -1,10 +1,6 @@ -using System; -using System.Net.Http; -using System.Text; +using System.Text; using System.Text.Json; using System.Text.Json.Serialization; -using System.Threading; -using System.Threading.Tasks; using KitX.Shared.CSharp.Device; using KitX.Shared.CSharp.WebCommand; using Serilog; diff --git a/KitX Clients/KitX Core/KitX.Core/Device/DevicesDiscoveryServer.cs b/KitX Clients/KitX Core/KitX.Core/Device/DevicesDiscoveryServer.cs index d39b119e..39caa0d8 100644 --- a/KitX Clients/KitX Core/KitX.Core/Device/DevicesDiscoveryServer.cs +++ b/KitX Clients/KitX Core/KitX.Core/Device/DevicesDiscoveryServer.cs @@ -1,13 +1,7 @@ -using KitX.Core.Configuration; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net; +using System.Net; using System.Net.NetworkInformation; using System.Net.Sockets; using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; using CTask = System.Threading.Tasks.Task; using KitX.Core.Contract.Configuration; using KitX.Core.Contract.Device; @@ -15,8 +9,6 @@ using KitX.Core.Contract.Plugin; using KitX.Shared.CSharp.Device; using Serilog; -using KitX.Core.DI; -using KitX.Core.Event; namespace KitX.Core.Device; diff --git a/KitX Clients/KitX Core/KitX.Core/Device/DevicesOrganizer.cs b/KitX Clients/KitX Core/KitX.Core/Device/DevicesOrganizer.cs index 1c7f9377..631fae0a 100644 --- a/KitX Clients/KitX Core/KitX.Core/Device/DevicesOrganizer.cs +++ b/KitX Clients/KitX Core/KitX.Core/Device/DevicesOrganizer.cs @@ -1,8 +1,4 @@ -using KitX.Core.Configuration; -using System; -using System.Collections.Generic; -using System.Text; -using System.Threading; +using System.Text; using KitX.Core.Contract.Configuration; using KitX.Core.Contract.Device; using KitX.Core.Contract.Event; @@ -10,7 +6,6 @@ using KitX.Shared.CSharp.Device; using Serilog; using Timer = System.Timers.Timer; -using KitX.Core.DI; namespace KitX.Core.Device; diff --git a/KitX Clients/KitX Core/KitX.Core/Device/DevicesServer.cs b/KitX Clients/KitX Core/KitX.Core/Device/DevicesServer.cs index 0d898177..2b0333fc 100644 --- a/KitX Clients/KitX Core/KitX.Core/Device/DevicesServer.cs +++ b/KitX Clients/KitX Core/KitX.Core/Device/DevicesServer.cs @@ -1,20 +1,13 @@ -using System; -using System.Collections.Generic; -using System.Collections.Concurrent; -using System.IO; -using System.Linq; +using System.Collections.Concurrent; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; -using System.Threading; -using System.Threading.Tasks; using KitX.Core.Contract.Device; using KitX.Core.Contract.Event; using KitX.Core.Contract.Plugin; using KitX.Core.Contract.Plugin.Events; using KitX.Core.Contract.Security; using KitX.Core.Event; -using KitX.Core.Security; using KitX.Shared.CSharp.Device; using KitX.Shared.CSharp.Security; using KitX.Shared.CSharp.WebCommand; @@ -24,7 +17,6 @@ using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Hosting.Server.Features; using Microsoft.AspNetCore.Http; -using KitX.Core.DI; using Microsoft.Extensions.DependencyInjection; using Serilog; diff --git a/KitX Clients/KitX Core/KitX.Core/Device/Events/PluginEventArgs.cs b/KitX Clients/KitX Core/KitX.Core/Device/Events/PluginEventArgs.cs index f1a4aabc..3487a47f 100644 --- a/KitX Clients/KitX Core/KitX.Core/Device/Events/PluginEventArgs.cs +++ b/KitX Clients/KitX Core/KitX.Core/Device/Events/PluginEventArgs.cs @@ -1,7 +1,4 @@ -using System; -using KitX.Shared.CSharp.WebCommand; - -namespace KitX.Core.Device.Events; +namespace KitX.Core.Device.Events; /// /// Plugin connected event arguments diff --git a/KitX Clients/KitX Core/KitX.Core/Device/NetworkHelper.cs b/KitX Clients/KitX Core/KitX.Core/Device/NetworkHelper.cs index c0282303..18396857 100644 --- a/KitX Clients/KitX Core/KitX.Core/Device/NetworkHelper.cs +++ b/KitX Clients/KitX Core/KitX.Core/Device/NetworkHelper.cs @@ -1,10 +1,7 @@ -using System; -using System.Linq; -using System.Net; +using System.Net; using System.Net.NetworkInformation; using System.Net.Sockets; using KitX.Shared.CSharp.Device; -using Common.BasicHelper.Core; using Serilog; namespace KitX.Core.Device; diff --git a/KitX Clients/KitX Core/KitX.Core/Device/OperatingSystemHelper.cs b/KitX Clients/KitX Core/KitX.Core/Device/OperatingSystemHelper.cs index e3ce96ce..d2dc55aa 100644 --- a/KitX Clients/KitX Core/KitX.Core/Device/OperatingSystemHelper.cs +++ b/KitX Clients/KitX Core/KitX.Core/Device/OperatingSystemHelper.cs @@ -1,5 +1,4 @@ -using System; -using KitX.Shared.CSharp.Device; +using KitX.Shared.CSharp.Device; namespace KitX.Core.Device; diff --git a/KitX Clients/KitX Core/KitX.Core/Device/PluginConnection.cs b/KitX Clients/KitX Core/KitX.Core/Device/PluginConnection.cs index a06229af..f69ef917 100644 --- a/KitX Clients/KitX Core/KitX.Core/Device/PluginConnection.cs +++ b/KitX Clients/KitX Core/KitX.Core/Device/PluginConnection.cs @@ -1,10 +1,6 @@ -using System; -using System.Text.Json; +using System.Text.Json; using Fleck; -using KitX.Core.Contract.Plugin; using KitX.Core.Contract.Plugin.Events; -using KitX.Core.Contract.Device; -using KitX.Shared.CSharp.Plugin; using KitX.Shared.CSharp.WebCommand; using Serilog; diff --git a/KitX Clients/KitX Core/KitX.Core/Device/PluginsServer.cs b/KitX Clients/KitX Core/KitX.Core/Device/PluginsServer.cs index 9c5e4565..6f5d8a12 100644 --- a/KitX Clients/KitX Core/KitX.Core/Device/PluginsServer.cs +++ b/KitX Clients/KitX Core/KitX.Core/Device/PluginsServer.cs @@ -1,17 +1,10 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text.Json; +using System.Text.Json; using System.Text.RegularExpressions; -using System.Threading.Tasks; using Fleck; -using KitX.Core; using KitX.Core.Contract.Plugin; using KitX.Core.Contract.Plugin.Events; using KitX.Core.Contract.Event; using KitX.Core.Event; -using KitX.Core.DI; -using KitX.Core.Plugin; using KitX.Shared.CSharp.Plugin; using KitX.Shared.CSharp.WebCommand; using Serilog; diff --git a/KitX Clients/KitX Core/KitX.Core/Event/EventArgs.cs b/KitX Clients/KitX Core/KitX.Core/Event/EventArgs.cs index 4e4bce94..64a8b4bb 100644 --- a/KitX Clients/KitX Core/KitX.Core/Event/EventArgs.cs +++ b/KitX Clients/KitX Core/KitX.Core/Event/EventArgs.cs @@ -1,6 +1,4 @@ -using System; -using KitX.Shared.CSharp.Device; -using KitX.Shared.CSharp.Plugin; +using KitX.Shared.CSharp.Plugin; namespace KitX.Core.Event; diff --git a/KitX Clients/KitX Core/KitX.Core/Event/EventService.cs b/KitX Clients/KitX Core/KitX.Core/Event/EventService.cs index 09c0ba2b..e9e50877 100644 --- a/KitX Clients/KitX Core/KitX.Core/Event/EventService.cs +++ b/KitX Clients/KitX Core/KitX.Core/Event/EventService.cs @@ -1,9 +1,5 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using KitX.Core.Contract.Event; +using KitX.Core.Contract.Event; using Serilog; -using KitX.Core.DI; namespace KitX.Core.Event; diff --git a/KitX Clients/KitX Core/KitX.Core/FileWatcher/FileWatcherManager.cs b/KitX Clients/KitX Core/KitX.Core/FileWatcher/FileWatcherManager.cs index ea95d3e6..1a357f58 100644 --- a/KitX Clients/KitX Core/KitX.Core/FileWatcher/FileWatcherManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/FileWatcher/FileWatcherManager.cs @@ -1,7 +1,4 @@ -using System; -using System.Collections.Generic; -using System.IO; -using KitX.Core.Contract.FileWatcher; +using KitX.Core.Contract.FileWatcher; using KitX.Core.DI; using Serilog; diff --git a/KitX Clients/KitX Core/KitX.Core/Hotkey/KeyHookManager.cs b/KitX Clients/KitX Core/KitX.Core/Hotkey/KeyHookManager.cs index 0a4af9e6..985fed83 100644 --- a/KitX Clients/KitX Core/KitX.Core/Hotkey/KeyHookManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/Hotkey/KeyHookManager.cs @@ -1,8 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using KitX.Core.Contract.Hotkey; +using KitX.Core.Contract.Hotkey; using SharpHook; using SharpHook.Data; using KitX.Core.DI; diff --git a/KitX Clients/KitX Core/KitX.Core/Plugin/PluginInstallation.cs b/KitX Clients/KitX Core/KitX.Core/Plugin/PluginInstallation.cs index f90031d1..7b163ad8 100644 --- a/KitX Clients/KitX Core/KitX.Core/Plugin/PluginInstallation.cs +++ b/KitX Clients/KitX Core/KitX.Core/Plugin/PluginInstallation.cs @@ -1,6 +1,4 @@ -using System; -using System.Collections.Generic; -using KitX.Core.Contract.Configuration; +using KitX.Core.Contract.Configuration; using KitX.Shared.CSharp.Device; using KitX.Shared.CSharp.Loader; using KitX.Shared.CSharp.Plugin; diff --git a/KitX Clients/KitX Core/KitX.Core/Plugin/PluginsManager.cs b/KitX Clients/KitX Core/KitX.Core/Plugin/PluginsManager.cs index 4b77dfd1..86e05e68 100644 --- a/KitX Clients/KitX Core/KitX.Core/Plugin/PluginsManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/Plugin/PluginsManager.cs @@ -1,13 +1,7 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; +using System.Collections.Concurrent; using System.Diagnostics; -using System.IO; -using System.Linq; using System.Text; using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; using KitX.Core.Contract.Configuration; using KitX.Core.Contract.Event; using KitX.Core.Contract.Plugin; diff --git a/KitX Clients/KitX Core/KitX.Core/Security/SecurityManager.cs b/KitX Clients/KitX Core/KitX.Core/Security/SecurityManager.cs index 91d6c2c5..21f849b9 100644 --- a/KitX Clients/KitX Core/KitX.Core/Security/SecurityManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/Security/SecurityManager.cs @@ -1,9 +1,5 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Security.Cryptography; +using System.Security.Cryptography; using System.Text; -using System.Text.Json; using KitX.Core.Contract.Configuration; using KitX.Core.Contract.Security; using KitX.Core.Contract.Device; @@ -12,7 +8,6 @@ using KitX.Shared.CSharp.Device; using KitX.Shared.CSharp.Security; using Serilog; -using KitX.Core.DI; namespace KitX.Core.Security; diff --git a/KitX Clients/KitX Core/KitX.Core/Statistics/StatisticsManager.cs b/KitX Clients/KitX Core/KitX.Core/Statistics/StatisticsManager.cs index abaad3fe..1aa8fdd4 100644 --- a/KitX Clients/KitX Core/KitX.Core/Statistics/StatisticsManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/Statistics/StatisticsManager.cs @@ -1,7 +1,4 @@ -using System; -using System.Collections.Generic; -using System.IO; -using KitX.Core.Contract.Statistics; +using KitX.Core.Contract.Statistics; using Serilog; using STimer = System.Timers.Timer; using KitX.Core.DI; diff --git a/KitX Clients/KitX Core/KitX.Core/Tasks/TasksManager.cs b/KitX Clients/KitX Core/KitX.Core/Tasks/TasksManager.cs index 93ab591a..dd8aa9a9 100644 --- a/KitX Clients/KitX Core/KitX.Core/Tasks/TasksManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/Tasks/TasksManager.cs @@ -1,7 +1,4 @@ -using System; -using System.Threading; -using System.Threading.Tasks; -using KitX.Core.Contract.Tasks; +using KitX.Core.Contract.Tasks; using Serilog; using CTask = System.Threading.Tasks.Task; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScriptServiceImpl.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScriptServiceImpl.cs index 84701bb7..aad46814 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScriptServiceImpl.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScriptServiceImpl.cs @@ -1,13 +1,6 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using KitX.Core.Contract.Workflow; -using KitX.Core.Device; +using KitX.Core.Contract.Workflow; using KitX.Core.Workflow.BlockScripting; -using KitX.Shared.CSharp.Plugin; using Serilog; -using Microsoft.Extensions.DependencyInjection; namespace KitX.Core.Workflow; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScopeManager.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScopeManager.cs index cccf4c47..82a0ba59 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScopeManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScopeManager.cs @@ -1,7 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using KitX.Core.Contract.Workflow; +using KitX.Core.Contract.Workflow; namespace KitX.Core.Workflow.BlockScripting; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutionGlobals.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutionGlobals.cs index 807e8255..b54bd4d2 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutionGlobals.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutionGlobals.cs @@ -1,8 +1,4 @@ -using System; -using System.Collections.Generic; -using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow; -using Serilog; +using KitX.Core.Contract.Workflow; namespace KitX.Core.Workflow.BlockScripting; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutor.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutor.cs index f1809160..bd2a42c7 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutor.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutor.cs @@ -1,14 +1,7 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; +using System.Diagnostics; using KitX.Core.Contract.Workflow; -using Microsoft.CodeAnalysis.CSharp.Syntax; -using KitX.Core.Workflow; -using KitX.Core.Workflow.Blueprint.CFG; using Serilog; +using KitX.Core.Workflow.CFG; namespace KitX.Core.Workflow.BlockScripting; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptParser.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptParser.cs index 0f823667..8e0e5f35 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptParser.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptParser.cs @@ -1,7 +1,4 @@ -using System; -using System.Linq; -using System.Threading.Tasks; -using KitX.Core.Contract.Workflow; +using KitX.Core.Contract.Workflow; using Serilog; namespace KitX.Core.Workflow.BlockScripting; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockStatementExtractor.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockStatementExtractor.cs index a368051b..6e638702 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockStatementExtractor.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockStatementExtractor.cs @@ -1,14 +1,11 @@ -using System.Collections.Generic; -using System.Linq; -using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.Blueprint.Pipeline; using Serilog; using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Blocks; -using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Functions; +using KitX.Core.Workflow.Pipeline; namespace KitX.Core.Workflow.BlockScripting; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockStructureRecognizer.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockStructureRecognizer.cs index 18ecf1e4..e47fa20a 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockStructureRecognizer.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockStructureRecognizer.cs @@ -1,6 +1,4 @@ -using System.Collections.Generic; -using System.Linq; -using Microsoft.CodeAnalysis.Text; +using Microsoft.CodeAnalysis.Text; using KitX.Core.Contract.Workflow; using Serilog; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockSyntaxValidator.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockSyntaxValidator.cs index ed601581..8a4b604b 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockSyntaxValidator.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockSyntaxValidator.cs @@ -1,6 +1,4 @@ -using System; -using System.Linq; -using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlueprintDebugger.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlueprintDebugger.cs index dc8d76b5..67ba71bb 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlueprintDebugger.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlueprintDebugger.cs @@ -1,7 +1,4 @@ -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; +using System.Collections.Concurrent; using KitX.Core.Contract.Workflow; using Serilog; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctionRegistry.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctionRegistry.cs index 53d052be..8f2d3014 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctionRegistry.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctionRegistry.cs @@ -1,8 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using KitX.Core.Contract.Workflow; +using System.Reflection; using Serilog; namespace KitX.Core.Workflow.BlockScripting; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/CFG2CSGenerator.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/CFG2CSGenerator.cs index 146cc866..bd2c7b7c 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/CFG2CSGenerator.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/CFG2CSGenerator.cs @@ -1,19 +1,12 @@ -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Reflection; -using System.Runtime.Loader; -using System.Threading; -using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.Blueprint; -using KitX.Core.Workflow.Blueprint.CFG; -using KitX.Core.Workflow.Blueprint.Pipeline; using Serilog; using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; +using KitX.Core.Workflow.Pipeline; +using KitX.Core.Workflow.CFG; namespace KitX.Core.Workflow.BlockScripting; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/CSCompiler.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/CSCompiler.cs index a7dd18a1..e6200f67 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/CSCompiler.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/CSCompiler.cs @@ -1,9 +1,7 @@ -using KitX.Core.Workflow.Blueprint.CFG; -using System; -using System.Collections.Generic; -using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.Blueprint.Pipeline; +using KitX.Core.Contract.Workflow; using Serilog; +using KitX.Core.Workflow.Pipeline; +using KitX.Core.Workflow.CFG; namespace KitX.Core.Workflow.BlockScripting; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/CompiledScriptEntry.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/CompiledScriptEntry.cs index 395f7462..703accb8 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/CompiledScriptEntry.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/CompiledScriptEntry.cs @@ -1,7 +1,4 @@ -using System; -using System.Reflection; -using System.Runtime.Loader; -using KitX.Core.Contract.Workflow; +using System.Runtime.Loader; using Serilog; namespace KitX.Core.Workflow.BlockScripting; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/CompiledScriptMeta.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/CompiledScriptMeta.cs index 32c2efaa..49ce95d5 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/CompiledScriptMeta.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/CompiledScriptMeta.cs @@ -1,6 +1,4 @@ -using System; - -namespace KitX.Core.Workflow.BlockScripting; +namespace KitX.Core.Workflow.BlockScripting; /// /// Metadata for a persisted compiled script assembly on disk. diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/HelperFunctionCodeGenerator.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/HelperFunctionCodeGenerator.cs index 97f57096..70f44e24 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/HelperFunctionCodeGenerator.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/HelperFunctionCodeGenerator.cs @@ -1,5 +1,4 @@ -using System.Collections.Generic; -using System.Text; +using System.Text; using KitX.Core.Contract.Workflow; namespace KitX.Core.Workflow.BlockScripting; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/IBuiltinFunctionDefinition.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/IBuiltinFunctionDefinition.cs index 32c03023..12ff085a 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/IBuiltinFunctionDefinition.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/IBuiltinFunctionDefinition.cs @@ -1,9 +1,7 @@ -using System.Collections.Generic; -using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.Blueprint; -using KitX.Core.Workflow.Blueprint.CFG; -using KitX.Core.Workflow.Blueprint.Pipeline; +using KitX.Core.Workflow.Pipeline; +using KitX.Core.Workflow.CFG; namespace KitX.Core.Workflow.BlockScripting; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ICompiledBlockScript.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ICompiledBlockScript.cs index 0b457bb3..d399f7e0 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ICompiledBlockScript.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ICompiledBlockScript.cs @@ -1,8 +1,4 @@ -using System.Threading; -using System.Threading.Tasks; -using KitX.Core.Contract.Workflow; - -namespace KitX.Core.Workflow.BlockScripting; +namespace KitX.Core.Workflow.BlockScripting; /// /// Represents a BlockScript compiled into a .NET assembly via Roslyn CSharpCompilation. diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptCompilationBackend.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptCompilationBackend.cs index 7579b4e6..400e68e6 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptCompilationBackend.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptCompilationBackend.cs @@ -1,8 +1,4 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Reflection; +using System.Reflection; using System.Runtime.Loader; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptPersistenceManager.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptPersistenceManager.cs index 8148ab8f..1f312967 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptPersistenceManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptPersistenceManager.cs @@ -1,7 +1,4 @@ -using System; -using System.IO; -using System.Text.Json; -using KitX.Core.Contract.Workflow; +using System.Text.Json; using Serilog; namespace KitX.Core.Workflow.BlockScripting; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlockScriptToBlueprintConverter.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlockScriptToBlueprintConverter.cs index eed697ac..47b21834 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlockScriptToBlueprintConverter.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlockScriptToBlueprintConverter.cs @@ -1,10 +1,7 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using KitX.Core.Contract.Workflow; +using KitX.Core.Contract.Workflow; using KitX.Core.Workflow.BlockScripting; -using KitX.Core.Workflow.Blueprint.CFG; -using KitX.Core.Workflow.Blueprint.Pipeline; +using KitX.Core.Workflow.CFG; +using KitX.Core.Workflow.Pipeline; using Serilog; namespace KitX.Core.Workflow.Blueprint; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintRenderDataService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintRenderDataService.cs index 628e7c02..05cdffa4 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintRenderDataService.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintRenderDataService.cs @@ -1,6 +1,4 @@ -using System.Collections.Generic; -using System.Linq; -using KitX.Core.Contract.Workflow; +using KitX.Core.Contract.Workflow; using Serilog; namespace KitX.Core.Workflow.Blueprint; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintService.cs index 9511894f..96c8c7c0 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintService.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintService.cs @@ -1,8 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using KitX.Core.Contract.Workflow; +using KitX.Core.Contract.Workflow; using Serilog; namespace KitX.Core.Workflow.Blueprint; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintToBlockScriptConverter.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintToBlockScriptConverter.cs index e8d31212..7502724a 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintToBlockScriptConverter.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintToBlockScriptConverter.cs @@ -1,7 +1,5 @@ -using System.Collections.Generic; -using System.Linq; -using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.Blueprint.CFG; +using KitX.Core.Contract.Workflow; +using KitX.Core.Workflow.CFG; using Serilog; namespace KitX.Core.Workflow.Blueprint; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BuiltinFunctionExportStrategyAdapter.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BuiltinFunctionExportStrategyAdapter.cs index 9976310b..7eea90d2 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BuiltinFunctionExportStrategyAdapter.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BuiltinFunctionExportStrategyAdapter.cs @@ -1,7 +1,6 @@ -using System.Collections.Generic; -using KitX.Core.Contract.Workflow; +using KitX.Core.Contract.Workflow; using KitX.Core.Workflow.BlockScripting; -using KitX.Core.Workflow.Blueprint.CFG; +using KitX.Core.Workflow.CFG; namespace KitX.Core.Workflow.Blueprint; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ConversionContext.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ConversionContext.cs index 2c010b08..a4651fd9 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ConversionContext.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ConversionContext.cs @@ -1,5 +1,4 @@ -using System.Collections.Generic; -using KitX.Core.Contract.Workflow; +using KitX.Core.Contract.Workflow; namespace KitX.Core.Workflow.Blueprint; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/LayoutService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/LayoutService.cs index a5dfc84b..a2520a81 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/LayoutService.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/LayoutService.cs @@ -1,7 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using KitX.Core.Contract.Workflow; +using KitX.Core.Contract.Workflow; using Serilog; namespace KitX.Core.Workflow.Blueprint; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeExportHelper.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeExportHelper.cs index 442295ce..628f70c2 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeExportHelper.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeExportHelper.cs @@ -1,8 +1,5 @@ -using System.Collections.Generic; -using System.Linq; -using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.Blueprint.Pipeline; - +using KitX.Core.Contract.Workflow; +using KitX.Core.Workflow.Pipeline; using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Pins; namespace KitX.Core.Workflow.Blueprint; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeRegistry.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeRegistry.cs index 105bdf72..6ddab745 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeRegistry.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeRegistry.cs @@ -1,7 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using KitX.Core.Contract.Workflow; +using KitX.Core.Contract.Workflow; using KitX.Core.Workflow.BlockScripting; using Serilog; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/BranchFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/BranchFunction.cs similarity index 92% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/BranchFunction.cs rename to KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/BranchFunction.cs index a3bf10b4..5760eb3e 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/BranchFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/BranchFunction.cs @@ -1,13 +1,10 @@ -using System.Collections.Generic; -using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.Blueprint; -using KitX.Core.Workflow.Blueprint.CFG; -using KitX.Core.Workflow.Blueprint.Pipeline; +using KitX.Core.Workflow.Pipeline; +using KitX.Core.Workflow.CFG; +using KitX.Core.Workflow.BlockScripting; -using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Pins; - -namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions +namespace KitX.Core.Workflow.BuiltinFunctions { /// /// Branch 内置函数 — 条件分支控制流。 diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/BreakFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/BreakFunction.cs similarity index 86% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/BreakFunction.cs rename to KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/BreakFunction.cs index 36aefec4..46244f1e 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/BreakFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/BreakFunction.cs @@ -1,11 +1,10 @@ -using System.Collections.Generic; -using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.Blueprint; -using KitX.Core.Workflow.Blueprint.CFG; -using KitX.Core.Workflow.Blueprint.Pipeline; +using KitX.Core.Workflow.Pipeline; +using KitX.Core.Workflow.CFG; +using KitX.Core.Workflow.BlockScripting; -namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions; +namespace KitX.Core.Workflow.BuiltinFunctions; /// /// Break 内置函数 — 退出当前循环。 diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/CreateWorkflowFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/CreateWorkflowFunction.cs similarity index 92% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/CreateWorkflowFunction.cs rename to KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/CreateWorkflowFunction.cs index 0ebb208a..4a211c6a 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/CreateWorkflowFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/CreateWorkflowFunction.cs @@ -1,14 +1,11 @@ -using System.Collections.Generic; -using System.Linq; -using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; -using KitX.Core.DI; -using KitX.Core.Workflow.Blueprint; -using KitX.Core.Workflow.Blueprint.CFG; -using KitX.Core.Workflow.Blueprint.Pipeline; using Serilog; +using KitX.Core.Workflow.Pipeline; +using KitX.Core.Workflow.CFG; +using KitX.Core.Workflow.BlockScripting; -namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions +namespace KitX.Core.Workflow.BuiltinFunctions { public class CreateWorkflowFunction : IBuiltinFunctionDefinition { diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/FlipFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/FlipFunction.cs similarity index 95% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/FlipFunction.cs rename to KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/FlipFunction.cs index 2865a2d0..36a147e6 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/FlipFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/FlipFunction.cs @@ -1,11 +1,10 @@ -using System.Collections.Generic; -using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.Blueprint; -using KitX.Core.Workflow.Blueprint.CFG; -using KitX.Core.Workflow.Blueprint.Pipeline; +using KitX.Core.Workflow.Pipeline; +using KitX.Core.Workflow.CFG; +using KitX.Core.Workflow.BlockScripting; -namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions +namespace KitX.Core.Workflow.BuiltinFunctions { /// /// Flip 内置函数 — 交替路由控制流。每次执行时交替选择两个输出分支之一。 diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/GetFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/GetFunction.cs similarity index 93% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/GetFunction.cs rename to KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/GetFunction.cs index d73f7cf7..6b659729 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/GetFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/GetFunction.cs @@ -1,12 +1,10 @@ -using System.Collections.Generic; -using System.Linq; -using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.Blueprint; -using KitX.Core.Workflow.Blueprint.CFG; -using KitX.Core.Workflow.Blueprint.Pipeline; +using KitX.Core.Workflow.Pipeline; +using KitX.Core.Workflow.CFG; +using KitX.Core.Workflow.BlockScripting; -namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions; +namespace KitX.Core.Workflow.BuiltinFunctions; /// /// Get builtin function — reads a variable value from global scope. diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/GetPluginInfoByNameFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/GetPluginInfoByNameFunction.cs similarity index 93% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/GetPluginInfoByNameFunction.cs rename to KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/GetPluginInfoByNameFunction.cs index 77416488..e147b612 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/GetPluginInfoByNameFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/GetPluginInfoByNameFunction.cs @@ -1,16 +1,13 @@ -using System.Collections.Generic; -using System.Linq; -using System.Text.Json; +using System.Text.Json; using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Plugin; using KitX.Core.Contract.Workflow; -using KitX.Core.DI; -using KitX.Core.Workflow.Blueprint; -using KitX.Core.Workflow.Blueprint.CFG; -using KitX.Core.Workflow.Blueprint.Pipeline; using Serilog; +using KitX.Core.Workflow.Pipeline; +using KitX.Core.Workflow.CFG; +using KitX.Core.Workflow.BlockScripting; -namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions +namespace KitX.Core.Workflow.BuiltinFunctions { public class GetPluginInfoByNameFunction : IBuiltinFunctionDefinition { diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/InstallPluginFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/InstallPluginFunction.cs similarity index 90% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/InstallPluginFunction.cs rename to KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/InstallPluginFunction.cs index 5797010b..0f807815 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/InstallPluginFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/InstallPluginFunction.cs @@ -1,15 +1,12 @@ -using System.Collections.Generic; -using System.Linq; -using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Plugin; using KitX.Core.Contract.Workflow; -using KitX.Core.DI; -using KitX.Core.Workflow.Blueprint; -using KitX.Core.Workflow.Blueprint.CFG; -using KitX.Core.Workflow.Blueprint.Pipeline; using Serilog; +using KitX.Core.Workflow.Pipeline; +using KitX.Core.Workflow.CFG; +using KitX.Core.Workflow.BlockScripting; -namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions +namespace KitX.Core.Workflow.BuiltinFunctions { public class InstallPluginFunction : IBuiltinFunctionDefinition { diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/JsonGetFieldFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/JsonGetFieldFunction.cs similarity index 95% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/JsonGetFieldFunction.cs rename to KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/JsonGetFieldFunction.cs index 2a878252..997844a5 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/JsonGetFieldFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/JsonGetFieldFunction.cs @@ -1,14 +1,12 @@ -using System.Collections.Generic; -using System.Linq; -using System.Text.Json; +using System.Text.Json; using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.Blueprint; -using KitX.Core.Workflow.Blueprint.CFG; -using KitX.Core.Workflow.Blueprint.Pipeline; using Serilog; +using KitX.Core.Workflow.Pipeline; +using KitX.Core.Workflow.CFG; +using KitX.Core.Workflow.BlockScripting; -namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions +namespace KitX.Core.Workflow.BuiltinFunctions { public class JsonGetFieldFunction : IBuiltinFunctionDefinition { diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/ListPluginNamesFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/ListPluginNamesFunction.cs similarity index 91% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/ListPluginNamesFunction.cs rename to KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/ListPluginNamesFunction.cs index e34408bc..80fe9728 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/ListPluginNamesFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/ListPluginNamesFunction.cs @@ -1,16 +1,13 @@ -using System.Collections.Generic; -using System.Linq; -using System.Text.Json; +using System.Text.Json; using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Plugin; using KitX.Core.Contract.Workflow; -using KitX.Core.DI; -using KitX.Core.Workflow.Blueprint; -using KitX.Core.Workflow.Blueprint.CFG; -using KitX.Core.Workflow.Blueprint.Pipeline; using Serilog; +using KitX.Core.Workflow.Pipeline; +using KitX.Core.Workflow.CFG; +using KitX.Core.Workflow.BlockScripting; -namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions +namespace KitX.Core.Workflow.BuiltinFunctions { public class ListPluginNamesFunction : IBuiltinFunctionDefinition { diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/ListWorkflowsFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/ListWorkflowsFunction.cs similarity index 91% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/ListWorkflowsFunction.cs rename to KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/ListWorkflowsFunction.cs index ac387da6..d7fd54c3 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/ListWorkflowsFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/ListWorkflowsFunction.cs @@ -1,15 +1,12 @@ -using System.Collections.Generic; -using System.Linq; -using System.Text.Json; +using System.Text.Json; using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; -using KitX.Core.DI; -using KitX.Core.Workflow.Blueprint; -using KitX.Core.Workflow.Blueprint.CFG; -using KitX.Core.Workflow.Blueprint.Pipeline; using Serilog; +using KitX.Core.Workflow.Pipeline; +using KitX.Core.Workflow.CFG; +using KitX.Core.Workflow.BlockScripting; -namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions +namespace KitX.Core.Workflow.BuiltinFunctions { public class ListWorkflowsFunction : IBuiltinFunctionDefinition { diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/LoopFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/LoopFunction.cs similarity index 92% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/LoopFunction.cs rename to KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/LoopFunction.cs index 746bcb6f..2912a6ba 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/LoopFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/LoopFunction.cs @@ -1,13 +1,10 @@ -using System.Collections.Generic; -using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.Blueprint; -using KitX.Core.Workflow.Blueprint.CFG; -using KitX.Core.Workflow.Blueprint.Pipeline; +using KitX.Core.Workflow.Pipeline; +using KitX.Core.Workflow.CFG; +using KitX.Core.Workflow.BlockScripting; -using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Pins; - -namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions +namespace KitX.Core.Workflow.BuiltinFunctions { /// /// Loop 内置函数 — 循环控制流。 diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PauseFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PauseFunction.cs similarity index 89% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PauseFunction.cs rename to KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PauseFunction.cs index 31ced3d9..c79489fd 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PauseFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PauseFunction.cs @@ -1,12 +1,10 @@ -using System.Collections.Generic; -using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.Blueprint; -using KitX.Core.Workflow.Blueprint.CFG; -using KitX.Core.Workflow.Blueprint.Pipeline; -using System.Threading; +using KitX.Core.Workflow.Pipeline; +using KitX.Core.Workflow.CFG; +using KitX.Core.Workflow.BlockScripting; -namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions +namespace KitX.Core.Workflow.BuiltinFunctions { /// /// Pause 内置函数 — 暂停执行指定毫秒数。 diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PluginCallFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PluginCallFunction.cs similarity index 91% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PluginCallFunction.cs rename to KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PluginCallFunction.cs index 6390ffed..4baa652e 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PluginCallFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PluginCallFunction.cs @@ -1,15 +1,11 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.Blueprint; -using KitX.Core.Workflow.Blueprint.CFG; -using KitX.Core.Workflow.Blueprint.Pipeline; -using KitX.Shared.CSharp.Device; using Serilog; +using KitX.Core.Workflow.Pipeline; +using KitX.Core.Workflow.CFG; +using KitX.Core.Workflow.BlockScripting; -namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions +namespace KitX.Core.Workflow.BuiltinFunctions { /// /// PluginCall 内置函数 — 调用本机插件函数。 diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PluginCallWithTargetFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PluginCallWithTargetFunction.cs similarity index 96% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PluginCallWithTargetFunction.cs rename to KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PluginCallWithTargetFunction.cs index a56fcc35..415eb40e 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PluginCallWithTargetFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PluginCallWithTargetFunction.cs @@ -1,16 +1,11 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; -using KitX.Core.Device; -using KitX.Core.Workflow.Blueprint; -using KitX.Core.Workflow.Blueprint.CFG; -using KitX.Core.Workflow.Blueprint.Pipeline; -using KitX.Shared.CSharp.Device; using Serilog; +using KitX.Core.Workflow.Pipeline; +using KitX.Core.Workflow.CFG; +using KitX.Core.Workflow.BlockScripting; -namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions +namespace KitX.Core.Workflow.BuiltinFunctions { /// /// PluginCallWithTarget 内置函数 — 调用远程设备上的插件。 diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PrintFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PrintFunction.cs similarity index 90% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PrintFunction.cs rename to KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PrintFunction.cs index 55999fae..a192ed6d 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/PrintFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PrintFunction.cs @@ -1,11 +1,10 @@ -using System.Collections.Generic; -using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.Blueprint; -using KitX.Core.Workflow.Blueprint.CFG; -using KitX.Core.Workflow.Blueprint.Pipeline; +using KitX.Core.Workflow.Pipeline; +using KitX.Core.Workflow.CFG; +using KitX.Core.Workflow.BlockScripting; -namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions +namespace KitX.Core.Workflow.BuiltinFunctions { /// /// Print 内置函数 — 输出值到控制台。 diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/ReadTextFileFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/ReadTextFileFunction.cs similarity index 90% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/ReadTextFileFunction.cs rename to KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/ReadTextFileFunction.cs index 77192d09..780ae264 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/ReadTextFileFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/ReadTextFileFunction.cs @@ -1,13 +1,11 @@ -using System.Collections.Generic; -using System.Linq; -using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.Blueprint; -using KitX.Core.Workflow.Blueprint.CFG; -using KitX.Core.Workflow.Blueprint.Pipeline; using Serilog; +using KitX.Core.Workflow.Pipeline; +using KitX.Core.Workflow.CFG; +using KitX.Core.Workflow.BlockScripting; -namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions +namespace KitX.Core.Workflow.BuiltinFunctions { public class ReadTextFileFunction : IBuiltinFunctionDefinition { diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/RunWorkflowFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/RunWorkflowFunction.cs similarity index 90% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/RunWorkflowFunction.cs rename to KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/RunWorkflowFunction.cs index 3f15a230..6280c735 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/RunWorkflowFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/RunWorkflowFunction.cs @@ -1,14 +1,11 @@ -using System.Collections.Generic; -using System.Linq; -using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; -using KitX.Core.DI; -using KitX.Core.Workflow.Blueprint; -using KitX.Core.Workflow.Blueprint.CFG; -using KitX.Core.Workflow.Blueprint.Pipeline; using Serilog; +using KitX.Core.Workflow.Pipeline; +using KitX.Core.Workflow.CFG; +using KitX.Core.Workflow.BlockScripting; -namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions +namespace KitX.Core.Workflow.BuiltinFunctions { public class RunWorkflowFunction : IBuiltinFunctionDefinition { diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/SetFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/SetFunction.cs similarity index 92% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/SetFunction.cs rename to KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/SetFunction.cs index ac07d1e6..4cb4e437 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/SetFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/SetFunction.cs @@ -1,12 +1,10 @@ -using System.Collections.Generic; -using System.Linq; -using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.Blueprint; -using KitX.Core.Workflow.Blueprint.CFG; -using KitX.Core.Workflow.Blueprint.Pipeline; +using KitX.Core.Workflow.Pipeline; +using KitX.Core.Workflow.CFG; +using KitX.Core.Workflow.BlockScripting; -namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions +namespace KitX.Core.Workflow.BuiltinFunctions { /// /// Set builtin function — writes a value to a global variable. diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/StartPluginFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/StartPluginFunction.cs similarity index 91% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/StartPluginFunction.cs rename to KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/StartPluginFunction.cs index 360fa803..32cd16ec 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/StartPluginFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/StartPluginFunction.cs @@ -1,15 +1,12 @@ -using System.Collections.Generic; -using System.Linq; -using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Plugin; using KitX.Core.Contract.Workflow; -using KitX.Core.DI; -using KitX.Core.Workflow.Blueprint; -using KitX.Core.Workflow.Blueprint.CFG; -using KitX.Core.Workflow.Blueprint.Pipeline; using Serilog; +using KitX.Core.Workflow.Pipeline; +using KitX.Core.Workflow.CFG; +using KitX.Core.Workflow.BlockScripting; -namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions +namespace KitX.Core.Workflow.BuiltinFunctions { public class StartPluginFunction : IBuiltinFunctionDefinition { diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/StopPluginFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/StopPluginFunction.cs similarity index 91% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/StopPluginFunction.cs rename to KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/StopPluginFunction.cs index c0eb0b32..4d95fd15 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/StopPluginFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/StopPluginFunction.cs @@ -1,15 +1,12 @@ -using System.Collections.Generic; -using System.Linq; -using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Plugin; using KitX.Core.Contract.Workflow; -using KitX.Core.DI; -using KitX.Core.Workflow.Blueprint; -using KitX.Core.Workflow.Blueprint.CFG; -using KitX.Core.Workflow.Blueprint.Pipeline; using Serilog; +using KitX.Core.Workflow.Pipeline; +using KitX.Core.Workflow.CFG; +using KitX.Core.Workflow.BlockScripting; -namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions +namespace KitX.Core.Workflow.BuiltinFunctions { public class StopPluginFunction : IBuiltinFunctionDefinition { diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/StopWorkflowFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/StopWorkflowFunction.cs similarity index 90% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/StopWorkflowFunction.cs rename to KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/StopWorkflowFunction.cs index 6a56f74f..8aec7178 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/StopWorkflowFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/StopWorkflowFunction.cs @@ -1,14 +1,11 @@ -using System.Collections.Generic; -using System.Linq; -using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; -using KitX.Core.DI; -using KitX.Core.Workflow.Blueprint; -using KitX.Core.Workflow.Blueprint.CFG; -using KitX.Core.Workflow.Blueprint.Pipeline; using Serilog; +using KitX.Core.Workflow.Pipeline; +using KitX.Core.Workflow.CFG; +using KitX.Core.Workflow.BlockScripting; -namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions +namespace KitX.Core.Workflow.BuiltinFunctions { public class StopWorkflowFunction : IBuiltinFunctionDefinition { diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/ToLoopCondFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/ToLoopCondFunction.cs similarity index 95% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/ToLoopCondFunction.cs rename to KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/ToLoopCondFunction.cs index 5e31cd8a..7ef2be43 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/ToLoopCondFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/ToLoopCondFunction.cs @@ -1,11 +1,10 @@ -using System.Collections.Generic; -using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.Blueprint; -using KitX.Core.Workflow.Blueprint.CFG; -using KitX.Core.Workflow.Blueprint.Pipeline; +using KitX.Core.Workflow.Pipeline; +using KitX.Core.Workflow.CFG; +using KitX.Core.Workflow.BlockScripting; -namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions +namespace KitX.Core.Workflow.BuiltinFunctions { /// /// ToLoopCond 内置函数 — 标记循环体的结尾并返回循环条件块。 diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/TryGetDeviceFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/TryGetDeviceFunction.cs similarity index 93% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/TryGetDeviceFunction.cs rename to KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/TryGetDeviceFunction.cs index b8cde159..c59eff23 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/TryGetDeviceFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/TryGetDeviceFunction.cs @@ -1,19 +1,13 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; using KitX.Core.Contract.Device; -using KitX.Core.Device; -using KitX.Core.Workflow.Blueprint; -using KitX.Core.Workflow.Blueprint.CFG; -using KitX.Core.Workflow.Blueprint.Pipeline; using KitX.Shared.CSharp.Device; using Serilog; -using Microsoft.Extensions.DependencyInjection; +using KitX.Core.Workflow.Pipeline; +using KitX.Core.Workflow.CFG; +using KitX.Core.Workflow.BlockScripting; -namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions +namespace KitX.Core.Workflow.BuiltinFunctions { /// /// TryGetDevice 内置函数 — 根据设备名称查找已连接设备。 diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/WriteTextFileFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/WriteTextFileFunction.cs similarity index 91% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/WriteTextFileFunction.cs rename to KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/WriteTextFileFunction.cs index 565b3cf4..3ca57189 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctions/WriteTextFileFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/WriteTextFileFunction.cs @@ -1,13 +1,11 @@ -using System.Collections.Generic; -using System.Linq; -using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.Blueprint; -using KitX.Core.Workflow.Blueprint.CFG; -using KitX.Core.Workflow.Blueprint.Pipeline; using Serilog; +using KitX.Core.Workflow.Pipeline; +using KitX.Core.Workflow.CFG; +using KitX.Core.Workflow.BlockScripting; -namespace KitX.Core.Workflow.BlockScripting.BuiltinFunctions +namespace KitX.Core.Workflow.BuiltinFunctions { public class WriteTextFileFunction : IBuiltinFunctionDefinition { diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/BP2CFGConverter.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/CFG/BP2CFGConverter.cs similarity index 99% rename from KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/BP2CFGConverter.cs rename to KitX Clients/KitX Core/KitX.Core/Workflow/CFG/BP2CFGConverter.cs index ce6c25f7..861dad47 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/BP2CFGConverter.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/CFG/BP2CFGConverter.cs @@ -1,16 +1,13 @@ -using System.Collections.Generic; -using System.Linq; -using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.BlockScripting; +using KitX.Core.Contract.Workflow; using KitX.Core.Workflow.Blueprint; -using KitX.Core.Workflow.Blueprint.Pipeline; using Microsoft.CodeAnalysis.CSharp.Syntax; using Serilog; using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Pins; using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Blocks; +using KitX.Core.Workflow.Pipeline; -namespace KitX.Core.Workflow.Blueprint.CFG; +namespace KitX.Core.Workflow.CFG; /// /// Builds a from a . @@ -217,7 +214,7 @@ or BlueprintNodeType.CallHelper var sourcePin = sourceNode.GetPinById(conn.SourcePinId); if (sourcePin == null) continue; - var pubVar = Pipeline.ExprUtils.GeneratePubVarName(pubVarCounter++); + var pubVar = ExprUtils.GeneratePubVarName(pubVarCounter++); conn.PubVarName = pubVar; autoPubVars.Add(pubVar); diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/BlockScriptSerializer.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/CFG/BlockScriptSerializer.cs similarity index 98% rename from KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/BlockScriptSerializer.cs rename to KitX Clients/KitX Core/KitX.Core/Workflow/CFG/BlockScriptSerializer.cs index 085a978d..85e02415 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/BlockScriptSerializer.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/CFG/BlockScriptSerializer.cs @@ -1,10 +1,8 @@ -using System.Linq; -using System.Text; +using System.Text; using KitX.Core.Contract.Workflow; - using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Blocks; -namespace KitX.Core.Workflow.Blueprint.CFG; +namespace KitX.Core.Workflow.CFG; /// /// Serializes a to source code string. diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/BlueprintDebugContext.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/CFG/BlueprintDebugContext.cs similarity index 59% rename from KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/BlueprintDebugContext.cs rename to KitX Clients/KitX Core/KitX.Core/Workflow/CFG/BlueprintDebugContext.cs index 4046047d..b4d1bbd7 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/BlueprintDebugContext.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/CFG/BlueprintDebugContext.cs @@ -1,6 +1,4 @@ -using System.Collections.Generic; - -namespace KitX.Core.Workflow.Blueprint.CFG; +namespace KitX.Core.Workflow.CFG; public class BlueprintDebugContext { diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFG2BSConverter.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/CFG/CFG2BSConverter.cs similarity index 98% rename from KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFG2BSConverter.cs rename to KitX Clients/KitX Core/KitX.Core/Workflow/CFG/CFG2BSConverter.cs index 97a386af..9fedd235 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFG2BSConverter.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/CFG/CFG2BSConverter.cs @@ -1,9 +1,7 @@ -using System.Linq; -using KitX.Core.Contract.Workflow; - +using KitX.Core.Contract.Workflow; using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Blocks; -namespace KitX.Core.Workflow.Blueprint.CFG; +namespace KitX.Core.Workflow.CFG; /// /// Generates a from a . diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGBlock.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/CFG/CFGBlock.cs similarity index 98% rename from KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGBlock.cs rename to KitX Clients/KitX Core/KitX.Core/Workflow/CFG/CFGBlock.cs index dd9bb666..c530b7bf 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGBlock.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/CFG/CFGBlock.cs @@ -1,4 +1,4 @@ -namespace KitX.Core.Workflow.Blueprint.CFG; +namespace KitX.Core.Workflow.CFG; /// /// Type of a CFG block, indicating its structural role in the control flow. diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGBuilderFromScript.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/CFG/CFGBuilderFromScript.cs similarity index 98% rename from KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGBuilderFromScript.cs rename to KitX Clients/KitX Core/KitX.Core/Workflow/CFG/CFGBuilderFromScript.cs index 2b7671f2..4aec370b 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGBuilderFromScript.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/CFG/CFGBuilderFromScript.cs @@ -1,12 +1,10 @@ -using System.Collections.Generic; -using System.Linq; -using KitX.Core.Contract.Workflow; +using KitX.Core.Contract.Workflow; using KitX.Core.Workflow.BlockScripting; - +using KitX.Core.Workflow.Pipeline; using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Blocks; using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Pins; -namespace KitX.Core.Workflow.Blueprint.CFG; +namespace KitX.Core.Workflow.CFG; /// /// Builds a from a AST. @@ -33,7 +31,7 @@ public CFGBuilderFromScript(List helperFunctions, BuiltinFunctio /// The BS2CFGConverter handles expression expansion; this method adds /// CFG structure (blocks, edges, block types) and type classification. /// - public ControlFlowGraph Build(BlockScript script, Pipeline.PipelineContext context) + public ControlFlowGraph Build(BlockScript script, PipelineContext context) { // Phase 1: Use BS2CFGConverter for expression expansion var formatter = new Pipeline.BS2CFGConverter(_helperFunctions, _functionRegistry); diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGConditionDuplicator.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/CFG/CFGConditionDuplicator.cs similarity index 94% rename from KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGConditionDuplicator.cs rename to KitX Clients/KitX Core/KitX.Core/Workflow/CFG/CFGConditionDuplicator.cs index 18057f92..2fd778b3 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGConditionDuplicator.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/CFG/CFGConditionDuplicator.cs @@ -1,10 +1,4 @@ -using System.Collections.Generic; -using System.Linq; -using KitX.Core.Contract.Workflow; - -using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Pins; - -namespace KitX.Core.Workflow.Blueprint.CFG; +namespace KitX.Core.Workflow.CFG; /// /// Duplicates Loop condition evaluation statements before each ToLoopCond diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGEdge.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/CFG/CFGEdge.cs similarity index 98% rename from KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGEdge.cs rename to KitX Clients/KitX Core/KitX.Core/Workflow/CFG/CFGEdge.cs index 7391bc60..defa55e3 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGEdge.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/CFG/CFGEdge.cs @@ -1,4 +1,4 @@ -namespace KitX.Core.Workflow.Blueprint.CFG; +namespace KitX.Core.Workflow.CFG; /// /// Type of a control flow edge between blocks. Making edge semantics diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGPipeline.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/CFG/CFGPipeline.cs similarity index 96% rename from KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGPipeline.cs rename to KitX Clients/KitX Core/KitX.Core/Workflow/CFG/CFGPipeline.cs index fe35308d..dca153a0 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGPipeline.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/CFG/CFGPipeline.cs @@ -1,8 +1,9 @@ -using KitX.Core.Contract.Workflow; +using KitX.Core.Contract.Workflow; using KitX.Core.Workflow.BlockScripting; -using KitX.Core.Workflow.Blueprint.Pipeline; +using KitX.Core.Workflow.Blueprint; +using KitX.Core.Workflow.Pipeline; -namespace KitX.Core.Workflow.Blueprint.CFG; +namespace KitX.Core.Workflow.CFG; /// /// Canonical CFG pipeline: five sub-path functions that compose into four main paths. diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGStatement.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/CFG/CFGStatement.cs similarity index 99% rename from KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGStatement.cs rename to KitX Clients/KitX Core/KitX.Core/Workflow/CFG/CFGStatement.cs index 59b9585d..60e9a567 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/CFGStatement.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/CFG/CFGStatement.cs @@ -1,4 +1,4 @@ -namespace KitX.Core.Workflow.Blueprint.CFG; +namespace KitX.Core.Workflow.CFG; /// /// Kinds of statements in the CFG. Unified statement kind replacing the former Pipeline.FormattedStatementKind. diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/ConstDeclaration.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/CFG/ConstDeclaration.cs similarity index 97% rename from KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/ConstDeclaration.cs rename to KitX Clients/KitX Core/KitX.Core/Workflow/CFG/ConstDeclaration.cs index bd335fb4..374767d3 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/ConstDeclaration.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/CFG/ConstDeclaration.cs @@ -1,4 +1,4 @@ -namespace KitX.Core.Workflow.Blueprint.CFG; +namespace KitX.Core.Workflow.CFG; /// /// A constant declaration from #ConstBlock. Preserves both the raw source diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/ControlFlowGraph.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/CFG/ControlFlowGraph.cs similarity index 97% rename from KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/ControlFlowGraph.cs rename to KitX Clients/KitX Core/KitX.Core/Workflow/CFG/ControlFlowGraph.cs index e14b2292..9e4ee88a 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/CFG/ControlFlowGraph.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/CFG/ControlFlowGraph.cs @@ -1,7 +1,7 @@ -using KitX.Core.Contract.Workflow; +using KitX.Core.Contract.Workflow; using System.Text; -namespace KitX.Core.Workflow.Blueprint.CFG; +namespace KitX.Core.Workflow.CFG; /// /// The Control Flow Graph — canonical intermediate representation for diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/ConstantValueRewriter.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/ConstantValueRewriter.cs index fcbcb9a1..3757a612 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/ConstantValueRewriter.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/ConstantValueRewriter.cs @@ -1,6 +1,4 @@ -using System; -using System.Collections.Generic; -using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/KcsFileService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/KcsFileService.cs index 67710e7b..d59794bb 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/KcsFileService.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/KcsFileService.cs @@ -1,7 +1,4 @@ -using System; -using System.IO; -using System.Text.Json; -using System.Threading.Tasks; +using System.Text.Json; using KitX.Core.Contract.Workflow; using Serilog; using Microsoft.CodeAnalysis; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/BS2CFGConverter.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Pipeline/BS2CFGConverter.cs similarity index 98% rename from KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/BS2CFGConverter.cs rename to KitX Clients/KitX Core/KitX.Core/Workflow/Pipeline/BS2CFGConverter.cs index 00b26299..4bfcb860 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/BS2CFGConverter.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Pipeline/BS2CFGConverter.cs @@ -1,16 +1,12 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; using KitX.Core.Workflow.BlockScripting; -using KitX.Core.Workflow.Blueprint.CFG; using Serilog; using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Functions; +using KitX.Core.Workflow.CFG; -namespace KitX.Core.Workflow.Blueprint.Pipeline; +namespace KitX.Core.Workflow.Pipeline; /// /// Phase 2: Takes a parsed BlockScript AST and produces a ControlFlowGraph diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/CFG2BPConverter.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Pipeline/CFG2BPConverter.cs similarity index 98% rename from KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/CFG2BPConverter.cs rename to KitX Clients/KitX Core/KitX.Core/Workflow/Pipeline/CFG2BPConverter.cs index d1ba91ca..f837eb1b 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/CFG2BPConverter.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Pipeline/CFG2BPConverter.cs @@ -1,15 +1,12 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using KitX.Core.Contract.Workflow; +using KitX.Core.Contract.Workflow; using KitX.Core.Workflow.BlockScripting; -using KitX.Core.Workflow.Blueprint.CFG; using Serilog; using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Pins; using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Functions; +using KitX.Core.Workflow.CFG; -namespace KitX.Core.Workflow.Blueprint.Pipeline; +namespace KitX.Core.Workflow.Pipeline; /// /// Phase 3: Creates all Blueprint nodes and exec flow edges from ControlFlowGraph. diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/DataEdgeBuilder.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Pipeline/DataEdgeBuilder.cs similarity index 97% rename from KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/DataEdgeBuilder.cs rename to KitX Clients/KitX Core/KitX.Core/Workflow/Pipeline/DataEdgeBuilder.cs index 7016867c..eb5e5477 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/DataEdgeBuilder.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Pipeline/DataEdgeBuilder.cs @@ -1,14 +1,11 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using KitX.Core.Contract.Workflow; +using KitX.Core.Contract.Workflow; using KitX.Core.Workflow.BlockScripting; -using KitX.Core.Workflow.Blueprint.CFG; using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Pins; using Serilog; +using KitX.Core.Workflow.CFG; -namespace KitX.Core.Workflow.Blueprint.Pipeline; +namespace KitX.Core.Workflow.Pipeline; /// /// Phase 4+5: Creates data edges from ControlFlowGraph argument analysis. diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ExprUtils.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Pipeline/ExprUtils.cs similarity index 96% rename from KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ExprUtils.cs rename to KitX Clients/KitX Core/KitX.Core/Workflow/Pipeline/ExprUtils.cs index d1bc51aa..fe6b2b74 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/ExprUtils.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Pipeline/ExprUtils.cs @@ -1,14 +1,8 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; -using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.BlockScripting; -namespace KitX.Core.Workflow.Blueprint.Pipeline; +namespace KitX.Core.Workflow.Pipeline; /// /// Shared expression-parsing utilities used across pipeline phases. diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineAssembler.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Pipeline/PipelineAssembler.cs similarity index 97% rename from KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineAssembler.cs rename to KitX Clients/KitX Core/KitX.Core/Workflow/Pipeline/PipelineAssembler.cs index acef627d..4b8d5258 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineAssembler.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Pipeline/PipelineAssembler.cs @@ -1,12 +1,7 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using KitX.Core.Contract.Workflow; +using KitX.Core.Contract.Workflow; using Serilog; -using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Pins; - -namespace KitX.Core.Workflow.Blueprint.Pipeline; +namespace KitX.Core.Workflow.Pipeline; /// /// Phase 6: Assembles the final Blueprint from all pipeline outputs. diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineContext.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Pipeline/PipelineContext.cs similarity index 96% rename from KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineContext.cs rename to KitX Clients/KitX Core/KitX.Core/Workflow/Pipeline/PipelineContext.cs index 76759489..8b6cd623 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/Pipeline/PipelineContext.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Pipeline/PipelineContext.cs @@ -1,11 +1,8 @@ -using System; -using KitX.Core.Workflow.Blueprint.CFG; -using System.Collections.Generic; - -using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Pins; +using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Pins; using KitX.Core.Contract.Workflow; +using KitX.Core.Workflow.CFG; -namespace KitX.Core.Workflow.Blueprint.Pipeline; +namespace KitX.Core.Workflow.Pipeline; /// /// Accumulated state flowing through all 6 conversion phases. diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/RealPluginManager.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/RealPluginManager.cs index 19c26af0..da5e4522 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/RealPluginManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/RealPluginManager.cs @@ -1,11 +1,6 @@ -using System; -using System.Collections.Concurrent; -using System.Linq; -using System.Net.Http; +using System.Collections.Concurrent; using System.Text; using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; using KitX.Core.Contract.Workflow; using KitX.Core.Contract.Plugin; using KitX.Core.Contract.Device; @@ -19,7 +14,6 @@ using KitX.Shared.CSharp.WebCommand; using KitX.Shared.CSharp.WebCommand.Infos; using Serilog; -using Microsoft.Extensions.DependencyInjection; namespace KitX.Core.Workflow; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/TriggerManager.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/TriggerManager.cs index 48b68034..e3b482a6 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/TriggerManager.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/TriggerManager.cs @@ -1,14 +1,7 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text.Json; -using System.Threading.Tasks; +using System.Text.Json; using KitX.Core.Contract.Workflow; -using KitX.Core.Contract.Device; using KitX.Core.Contract.Event; using KitX.Core.Contract.Plugin; -using KitX.Core.Contract.Plugin.Events; -using KitX.Core.Device; using KitX.Shared.CSharp.WebCommand; using KitX.Shared.CSharp.WebCommand.Infos; using Serilog; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowCase.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowCase.cs index 9fc26010..2d567ec1 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowCase.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowCase.cs @@ -1,5 +1,4 @@ -using System; -using KitX.Core.Contract.Workflow; +using KitX.Core.Contract.Workflow; namespace KitX.Core.Workflow; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowManagementService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowManagementService.cs index 85b63395..c1a09926 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowManagementService.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowManagementService.cs @@ -1,10 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using KitX.Core.Contract.Workflow; -using KitX.Shared.CSharp.Plugin; +using KitX.Core.Contract.Workflow; using Serilog; namespace KitX.Core.Workflow; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowOutput.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowOutput.cs index b28e5e44..b8ecc238 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowOutput.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowOutput.cs @@ -1,5 +1,4 @@ -using System; -using System.Collections.Concurrent; +using System.Collections.Concurrent; using System.Text; namespace KitX.Core.Workflow; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowPluginService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowPluginService.cs index 8c2bdce1..f0797b1f 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowPluginService.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowPluginService.cs @@ -1,9 +1,5 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using KitX.Core.Contract.Workflow; +using KitX.Core.Contract.Workflow; using KitX.Core.Contract.Plugin; -using KitX.Core.Device; using KitX.Core.DI; using KitX.Shared.CSharp.Plugin; using Microsoft.CodeAnalysis.CSharp; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowRuntimeState.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowRuntimeState.cs index 19771702..41f89f6f 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowRuntimeState.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowRuntimeState.cs @@ -1,5 +1,4 @@ -using System.Collections.Generic; -using Csharpell.Core; +using Csharpell.Core; using KitX.Core.Contract.Workflow; using KitX.Core.Workflow.BlockScripting; using KitX.Shared.CSharp.Plugin; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs index 83a5aaf5..cf1f48c5 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs @@ -1,9 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using KitX.Core.Contract.Workflow; -using KitX.Core.Device; +using KitX.Core.Contract.Workflow; using KitX.Core.DI; using KitX.Shared.CSharp.Plugin; using Serilog; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowStorageService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowStorageService.cs index c5310855..ac7752b8 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowStorageService.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowStorageService.cs @@ -1,9 +1,4 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text.Json; -using System.Threading.Tasks; +using System.Text.Json; using KitX.Core.Contract.Workflow; using KitX.Core.DI; using Serilog; From 6c9ff71eb7c324d4259673871da6433edb1c7e17 Mon Sep 17 00:00:00 2001 From: StarInk Date: Thu, 28 May 2026 23:27:06 +0200 Subject: [PATCH 073/110] =?UTF-8?q?=F0=9F=94=A7=20Fix(CFG):=20=E4=B8=B4?= =?UTF-8?q?=E6=97=B6=E4=BF=AE=E5=A4=8D=E6=9D=A1=E4=BB=B6=E8=A1=A8=E8=BE=BE?= =?UTF-8?q?=E5=BC=8F=E4=BF=AE=E5=89=AA=E5=92=8CCallNode=E5=AE=8C=E6=95=B4?= =?UTF-8?q?=E5=87=BD=E6=95=B0=E5=90=8D=E6=9E=84=E5=BB=BA=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../KitX Core/KitX.Core/Workflow/CFG/BP2CFGConverter.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/CFG/BP2CFGConverter.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/CFG/BP2CFGConverter.cs index 861dad47..5cfaf14c 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/CFG/BP2CFGConverter.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/CFG/BP2CFGConverter.cs @@ -908,6 +908,7 @@ private CFGStatement ConvertBlockStatementToCfgStatement(BlockStatement blockStm cfgStmt.TrueBlockName = flow.TrueBlockName; cfgStmt.FalseBlockName = flow.FalseBlockName; cfgStmt.ToLoopCondReturnTo = flow.ToLoopCondReturnTo; + cfgStmt.ConditionPubVar = flow.ConditionExpression?.Trim(); break; case ExpressionStatement expr: @@ -968,6 +969,11 @@ private CFGStatement ConvertBlockStatementToCfgStatement(BlockStatement blockStm if (outputPin != null) cfgStmt.PubVarTarget = NodeExportHelper.FindOutputPubVar(node, outputPin.Name, _currentCtx); } + + if (node is CallNode callNode) + cfgStmt.FullFunctionName = string.IsNullOrEmpty(callNode.PluginName) + ? callNode.FunctionName + : $"{callNode.PluginName}.{callNode.FunctionName}"; break; default: From ed785a8d9444b08d44baa5491cc1d27ee54903c6 Mon Sep 17 00:00:00 2001 From: StarInk Date: Sat, 13 Jun 2026 21:38:40 +0200 Subject: [PATCH 074/110] =?UTF-8?q?=F0=9F=94=A7=20Fix(Workflow):=20Test=20?= =?UTF-8?q?M(PluginCallWithTarget=20=E8=B7=A8=E8=AE=BE=E5=A4=87=E8=B0=83?= =?UTF-8?q?=E7=94=A8)=E5=8A=9F=E8=83=BD=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../KitX.Core.BluePrint.Test/Program.cs | 2 +- .../BlockScripting/CFG2CSGenerator.cs | 18 +++++++- .../Workflow/Pipeline/CFG2BPConverter.cs | 42 +++++++++++++------ KitX Clients/KitX Dashboard | 2 +- 4 files changed, 49 insertions(+), 15 deletions(-) diff --git a/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs b/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs index e0270663..b0f22bad 100644 --- a/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs +++ b/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs @@ -6,7 +6,7 @@ using KitX.Core.DI; using KitX.Core.Contract.Workflow; using KitX.Core.Workflow.Blueprint; -using KitX.Core.Workflow.Blueprint.CFG; +using KitX.Core.Workflow.CFG; using KitX.Core.Workflow.BlockScripting; namespace KitX.Core.BluePrint.Test; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/CFG2CSGenerator.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/CFG2CSGenerator.cs index bd2c7b7c..140684f0 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/CFG2CSGenerator.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/CFG2CSGenerator.cs @@ -734,11 +734,27 @@ internal static SwitchSectionSyntax GenerateFormattedBlockCase( var callExpr = BuildPluginCallWithTargetExpression(stmt, pubVarTypes); if (stmt.PubVarTarget != null) { + // G.PluginCallWithTarget returns object? — wrap in ConvertTo when + // assigning to a typed PubVar, mirroring the Assignment/Expression case. var typeName = pubVarTypes.GetValueOrDefault(stmt.PubVarTarget, "object"); + ExpressionSyntax initExpr = typeName != "object" + ? BuildConvertToInvocation(typeName, callExpr) + : callExpr; caseStatements.Add(LocalDeclarationStatement( VariableDeclaration(ParseTypeName(typeName)) .AddVariables(VariableDeclarator(Identifier(stmt.PubVarTarget)) - .WithInitializer(EqualsValueClause(callExpr))))); + .WithInitializer(EqualsValueClause(initExpr))))); + + // Sync PubVar to globals so debugger sees the value + caseStatements.Add(ExpressionStatement( + InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("G"), IdentifierName("Set")), + ArgumentList(SeparatedList(new[] + { + Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(stmt.PubVarTarget))), + Argument(IdentifierName(stmt.PubVarTarget)) + }))))); } else { diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Pipeline/CFG2BPConverter.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Pipeline/CFG2BPConverter.cs index f837eb1b..e3160b72 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Pipeline/CFG2BPConverter.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Pipeline/CFG2BPConverter.cs @@ -112,6 +112,13 @@ private void ProcessBlock(CFGBlock block, PipelineContext context) private BlueprintNode? ProcessStatement(CFGStatement stmt, string blockName, PipelineContext context, ref BlueprintNode? prevNode, ref string? prevStmtId) { + // PluginCallWithTarget must become an explicit CallNode carrying TargetDevice. + // Route it before the registry dispatch — otherwise the registry creates a + // BuiltinFunctionNode that ConfigureNode (which expects a CallNode) cannot convert, + // leaving TargetDevice unset and producing no CallNode for the test to find. + if (stmt.FunctionName == "PluginCallWithTarget") + return ProcessCallOrAssignment(stmt, context, ref prevNode, ref prevStmtId); + // Registry path: handle all registered builtin functions if (_functionRegistry != null && !string.IsNullOrEmpty(stmt.FunctionName)) { @@ -203,7 +210,28 @@ private bool IsRegistryFlowControlTerminator(CFGStatement stmt) // --- Create new node --- BlueprintNode mainNode; - if (_functionRegistry != null && _functionRegistry.Get(stmt.FunctionName!) is { } funcDef) + if (stmt.FunctionName == "PluginCallWithTarget") + { + // PluginCallWithTarget must be an explicit CallNode carrying TargetDevice. + // Handle BEFORE the builtin registry dispatch — otherwise the registry shadows + // it (PluginCallWithTarget is a registered builtin) and TargetDevice is never set. + var callNode = (CallNode)_registry.Create(BlueprintNodeType.Call); + var args = stmt.Arguments; + // G.PluginCallWithTarget("plugin", "method", "device", ...) + // Arguments[0]=pluginName, [1]=methodName, [2]=targetDevice + callNode.PluginName = args?.Count > 0 ? StripQuotes(args[0]) : ""; + callNode.FunctionName = args?.Count > 1 ? StripQuotes(args[1]) : ""; + callNode.TargetDevice = args?.Count > 2 ? StripQuotes(args[2]) : null; + // Preserve extra arguments (beyond plugin/method/device) for BS round-trip, + // mirroring PluginCallWithTargetFunction.ConfigureNode. + if (args != null && args.Count > 3) + callNode.ExtraArguments = args.Skip(3).ToList(); + mainNode = callNode; + + // Add parameter pins based on argument count + AddParamPins(mainNode, stmt.FunctionName!, stmt.Arguments?.Count ?? 0); + } + else if (_functionRegistry != null && _functionRegistry.Get(stmt.FunctionName!) is { } funcDef) { mainNode = _registry.CreateBuiltinFunctionNode(stmt.FunctionName!); mainNode = funcDef.ConfigureNode(mainNode, stmt); @@ -222,17 +250,7 @@ private bool IsRegistryFlowControlTerminator(CFGStatement stmt) var callNode = (CallNode)_registry.Create(BlueprintNodeType.Call); // Parse plugin name from full dotted method name (e.g. "TestPlugin.WPF.Core.HelloKitX") - // PluginCallWithTarget has G.PluginCallWithTarget as FullFunctionName, handle it first - if (stmt.FunctionName == "PluginCallWithTarget") - { - // G.PluginCallWithTarget("plugin", "method", "device", ...) - // Arguments[0]=pluginName, [1]=methodName, [2]=targetDevice - var args = stmt.Arguments; - callNode.PluginName = args?.Count > 0 ? StripQuotes(args[0]) : ""; - callNode.FunctionName = args?.Count > 1 ? StripQuotes(args[1]) : ""; - callNode.TargetDevice = args?.Count > 2 ? StripQuotes(args[2]) : null; - } - else if (!string.IsNullOrEmpty(stmt.FullFunctionName) && stmt.FullFunctionName.Contains('.')) + if (!string.IsNullOrEmpty(stmt.FullFunctionName) && stmt.FullFunctionName.Contains('.')) { var lastDot = stmt.FullFunctionName.LastIndexOf('.'); callNode.PluginName = stmt.FullFunctionName.Substring(0, lastDot); diff --git a/KitX Clients/KitX Dashboard b/KitX Clients/KitX Dashboard index 168eb05b..5514abad 160000 --- a/KitX Clients/KitX Dashboard +++ b/KitX Clients/KitX Dashboard @@ -1 +1 @@ -Subproject commit 168eb05bf6bbe72cc5cdb118bc162f7808a231b6 +Subproject commit 5514abad213e046f385d6248f3d5d79455ca5e30 From ce988196a275449d8b1d6082c605120600b5f5a7 Mon Sep 17 00:00:00 2001 From: StarInk Date: Sun, 14 Jun 2026 08:22:24 +0200 Subject: [PATCH 075/110] =?UTF-8?q?=F0=9F=A7=A9=20Refactor(Workflow):=20?= =?UTF-8?q?=E5=BD=92=E6=8B=A2=E8=BD=AC=E6=8D=A2=E5=99=A8=E8=87=B3=20Conver?= =?UTF-8?q?sion/=E3=80=81=E7=A7=BB=E9=99=A4=E6=AD=BB=E4=BB=A3=E7=A0=81-?= =?UTF-8?q?=E6=9C=80=E7=BB=88=E9=87=8D=E6=9E=84=E4=BC=98=E5=8C=96=E9=98=B6?= =?UTF-8?q?=E6=AE=B50?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../KitX.Core.BluePrint.Test/Program.cs | 1 + .../DI/CoreServiceCollectionExtensions.cs | 1 + .../BlockScripting/BlockScriptExecutor.cs | 1 + .../BlockScripting/BlockStatementExtractor.cs | 2 +- .../Workflow/BlockScripting/CSCompiler.cs | 2 +- .../IBuiltinFunctionDefinition.cs | 2 +- .../Workflow/Blueprint/BlueprintService.cs | 1 + .../Workflow/Blueprint/NodeExportHelper.cs | 2 +- .../BuiltinFunctions/BranchFunction.cs | 2 +- .../BuiltinFunctions/BreakFunction.cs | 2 +- .../CreateWorkflowFunction.cs | 2 +- .../Workflow/BuiltinFunctions/FlipFunction.cs | 2 +- .../Workflow/BuiltinFunctions/GetFunction.cs | 2 +- .../GetPluginInfoByNameFunction.cs | 2 +- .../BuiltinFunctions/InstallPluginFunction.cs | 2 +- .../BuiltinFunctions/JsonGetFieldFunction.cs | 2 +- .../ListPluginNamesFunction.cs | 2 +- .../BuiltinFunctions/ListWorkflowsFunction.cs | 2 +- .../Workflow/BuiltinFunctions/LoopFunction.cs | 2 +- .../BuiltinFunctions/PauseFunction.cs | 2 +- .../BuiltinFunctions/PluginCallFunction.cs | 2 +- .../PluginCallWithTargetFunction.cs | 2 +- .../BuiltinFunctions/PrintFunction.cs | 2 +- .../BuiltinFunctions/ReadTextFileFunction.cs | 2 +- .../BuiltinFunctions/RunWorkflowFunction.cs | 2 +- .../Workflow/BuiltinFunctions/SetFunction.cs | 2 +- .../BuiltinFunctions/StartPluginFunction.cs | 2 +- .../BuiltinFunctions/StopPluginFunction.cs | 2 +- .../BuiltinFunctions/StopWorkflowFunction.cs | 2 +- .../BuiltinFunctions/ToLoopCondFunction.cs | 2 +- .../BuiltinFunctions/TryGetDeviceFunction.cs | 2 +- .../BuiltinFunctions/WriteTextFileFunction.cs | 2 +- .../Workflow/CFG/CFGBuilderFromScript.cs | 277 ------------------ .../{CFG => Conversion}/BP2CFGConverter.cs | 5 +- .../BS2CFGConverter.cs | 3 +- .../BlockScriptSerializer.cs | 5 +- .../BlockScriptToBlueprintConverter.cs | 4 +- .../BlueprintToBlockScriptConverter.cs | 4 +- .../BuiltinFunctionExportStrategyAdapter.cs | 3 +- .../CFG2BPConverter.cs | 4 +- .../{CFG => Conversion}/CFG2BSConverter.cs | 5 +- .../CFG2CSGenerator.cs | 5 +- .../CFGConditionDuplicator.cs | 0 .../{CFG => Conversion}/CFGPipeline.cs | 4 +- .../ConversionContext.cs | 5 +- .../DataEdgeBuilder.cs | 3 +- .../{Pipeline => Conversion}/ExprUtils.cs | 5 +- .../PipelineAssembler.cs | 5 +- .../PipelineContext.cs | 4 +- 49 files changed, 76 insertions(+), 325 deletions(-) delete mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/CFG/CFGBuilderFromScript.cs rename KitX Clients/KitX Core/KitX.Core/Workflow/{CFG => Conversion}/BP2CFGConverter.cs (99%) rename KitX Clients/KitX Core/KitX.Core/Workflow/{Pipeline => Conversion}/BS2CFGConverter.cs (99%) rename KitX Clients/KitX Core/KitX.Core/Workflow/{CFG => Conversion}/BlockScriptSerializer.cs (96%) rename KitX Clients/KitX Core/KitX.Core/Workflow/{Blueprint => Conversion}/BlockScriptToBlueprintConverter.cs (98%) rename KitX Clients/KitX Core/KitX.Core/Workflow/{Blueprint => Conversion}/BlueprintToBlockScriptConverter.cs (96%) rename KitX Clients/KitX Core/KitX.Core/Workflow/{Blueprint => Conversion}/BuiltinFunctionExportStrategyAdapter.cs (94%) rename KitX Clients/KitX Core/KitX.Core/Workflow/{Pipeline => Conversion}/CFG2BPConverter.cs (99%) rename KitX Clients/KitX Core/KitX.Core/Workflow/{CFG => Conversion}/CFG2BSConverter.cs (97%) rename KitX Clients/KitX Core/KitX.Core/Workflow/{BlockScripting => Conversion}/CFG2CSGenerator.cs (99%) rename KitX Clients/KitX Core/KitX.Core/Workflow/{CFG => Conversion}/CFGConditionDuplicator.cs (100%) rename KitX Clients/KitX Core/KitX.Core/Workflow/{CFG => Conversion}/CFGPipeline.cs (98%) rename KitX Clients/KitX Core/KitX.Core/Workflow/{Blueprint => Conversion}/ConversionContext.cs (94%) rename KitX Clients/KitX Core/KitX.Core/Workflow/{Pipeline => Conversion}/DataEdgeBuilder.cs (99%) rename KitX Clients/KitX Core/KitX.Core/Workflow/{Pipeline => Conversion}/ExprUtils.cs (97%) rename KitX Clients/KitX Core/KitX.Core/Workflow/{Pipeline => Conversion}/PipelineAssembler.cs (98%) rename KitX Clients/KitX Core/KitX.Core/Workflow/{Pipeline => Conversion}/PipelineContext.cs (98%) diff --git a/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs b/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs index b0f22bad..bb302d53 100644 --- a/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs +++ b/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs @@ -9,6 +9,7 @@ using KitX.Core.Workflow.CFG; using KitX.Core.Workflow.BlockScripting; +using KitX.Core.Workflow.Conversion; namespace KitX.Core.BluePrint.Test; public class Program diff --git a/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs b/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs index 3756c514..cf406e26 100644 --- a/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs +++ b/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs @@ -27,6 +27,7 @@ using KitX.Core.Event; using Serilog; +using KitX.Core.Workflow.Conversion; namespace KitX.Core.DI; /// diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutor.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutor.cs index bd2a42c7..95389970 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutor.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutor.cs @@ -3,6 +3,7 @@ using Serilog; using KitX.Core.Workflow.CFG; +using KitX.Core.Workflow.Conversion; namespace KitX.Core.Workflow.BlockScripting; /// diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockStatementExtractor.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockStatementExtractor.cs index 6e638702..bb916afd 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockStatementExtractor.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockStatementExtractor.cs @@ -5,7 +5,7 @@ using Serilog; using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Blocks; -using KitX.Core.Workflow.Pipeline; +using KitX.Core.Workflow.Conversion; namespace KitX.Core.Workflow.BlockScripting; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/CSCompiler.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/CSCompiler.cs index e6200f67..6852576d 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/CSCompiler.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/CSCompiler.cs @@ -1,6 +1,6 @@ using KitX.Core.Contract.Workflow; using Serilog; -using KitX.Core.Workflow.Pipeline; +using KitX.Core.Workflow.Conversion; using KitX.Core.Workflow.CFG; namespace KitX.Core.Workflow.BlockScripting; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/IBuiltinFunctionDefinition.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/IBuiltinFunctionDefinition.cs index 12ff085a..340cb2d1 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/IBuiltinFunctionDefinition.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/IBuiltinFunctionDefinition.cs @@ -1,6 +1,6 @@ using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.Pipeline; +using KitX.Core.Workflow.Conversion; using KitX.Core.Workflow.CFG; namespace KitX.Core.Workflow.BlockScripting; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintService.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintService.cs index 96c8c7c0..f129dccf 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintService.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintService.cs @@ -1,6 +1,7 @@ using KitX.Core.Contract.Workflow; using Serilog; +using KitX.Core.Workflow.Conversion; namespace KitX.Core.Workflow.Blueprint; /// diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeExportHelper.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeExportHelper.cs index 628f70c2..0a9bdcde 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeExportHelper.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeExportHelper.cs @@ -1,5 +1,5 @@ using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.Pipeline; +using KitX.Core.Workflow.Conversion; using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Pins; namespace KitX.Core.Workflow.Blueprint; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/BranchFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/BranchFunction.cs index 5760eb3e..b230fb24 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/BranchFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/BranchFunction.cs @@ -1,6 +1,6 @@ using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.Pipeline; +using KitX.Core.Workflow.Conversion; using KitX.Core.Workflow.CFG; using KitX.Core.Workflow.BlockScripting; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/BreakFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/BreakFunction.cs index 46244f1e..a10def3a 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/BreakFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/BreakFunction.cs @@ -1,6 +1,6 @@ using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.Pipeline; +using KitX.Core.Workflow.Conversion; using KitX.Core.Workflow.CFG; using KitX.Core.Workflow.BlockScripting; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/CreateWorkflowFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/CreateWorkflowFunction.cs index 4a211c6a..c8487f6d 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/CreateWorkflowFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/CreateWorkflowFunction.cs @@ -1,7 +1,7 @@ using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; using Serilog; -using KitX.Core.Workflow.Pipeline; +using KitX.Core.Workflow.Conversion; using KitX.Core.Workflow.CFG; using KitX.Core.Workflow.BlockScripting; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/FlipFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/FlipFunction.cs index 36a147e6..eefaab76 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/FlipFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/FlipFunction.cs @@ -1,6 +1,6 @@ using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.Pipeline; +using KitX.Core.Workflow.Conversion; using KitX.Core.Workflow.CFG; using KitX.Core.Workflow.BlockScripting; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/GetFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/GetFunction.cs index 6b659729..a2ab9da5 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/GetFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/GetFunction.cs @@ -1,6 +1,6 @@ using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.Pipeline; +using KitX.Core.Workflow.Conversion; using KitX.Core.Workflow.CFG; using KitX.Core.Workflow.BlockScripting; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/GetPluginInfoByNameFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/GetPluginInfoByNameFunction.cs index e147b612..6dd6f900 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/GetPluginInfoByNameFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/GetPluginInfoByNameFunction.cs @@ -3,7 +3,7 @@ using KitX.Core.Contract.Plugin; using KitX.Core.Contract.Workflow; using Serilog; -using KitX.Core.Workflow.Pipeline; +using KitX.Core.Workflow.Conversion; using KitX.Core.Workflow.CFG; using KitX.Core.Workflow.BlockScripting; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/InstallPluginFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/InstallPluginFunction.cs index 0f807815..922bfb57 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/InstallPluginFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/InstallPluginFunction.cs @@ -2,7 +2,7 @@ using KitX.Core.Contract.Plugin; using KitX.Core.Contract.Workflow; using Serilog; -using KitX.Core.Workflow.Pipeline; +using KitX.Core.Workflow.Conversion; using KitX.Core.Workflow.CFG; using KitX.Core.Workflow.BlockScripting; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/JsonGetFieldFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/JsonGetFieldFunction.cs index 997844a5..ab785847 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/JsonGetFieldFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/JsonGetFieldFunction.cs @@ -2,7 +2,7 @@ using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; using Serilog; -using KitX.Core.Workflow.Pipeline; +using KitX.Core.Workflow.Conversion; using KitX.Core.Workflow.CFG; using KitX.Core.Workflow.BlockScripting; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/ListPluginNamesFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/ListPluginNamesFunction.cs index 80fe9728..6993e964 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/ListPluginNamesFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/ListPluginNamesFunction.cs @@ -3,7 +3,7 @@ using KitX.Core.Contract.Plugin; using KitX.Core.Contract.Workflow; using Serilog; -using KitX.Core.Workflow.Pipeline; +using KitX.Core.Workflow.Conversion; using KitX.Core.Workflow.CFG; using KitX.Core.Workflow.BlockScripting; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/ListWorkflowsFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/ListWorkflowsFunction.cs index d7fd54c3..136ac494 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/ListWorkflowsFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/ListWorkflowsFunction.cs @@ -2,7 +2,7 @@ using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; using Serilog; -using KitX.Core.Workflow.Pipeline; +using KitX.Core.Workflow.Conversion; using KitX.Core.Workflow.CFG; using KitX.Core.Workflow.BlockScripting; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/LoopFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/LoopFunction.cs index 2912a6ba..4e3bb7ed 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/LoopFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/LoopFunction.cs @@ -1,6 +1,6 @@ using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.Pipeline; +using KitX.Core.Workflow.Conversion; using KitX.Core.Workflow.CFG; using KitX.Core.Workflow.BlockScripting; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PauseFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PauseFunction.cs index c79489fd..70f0ae25 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PauseFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PauseFunction.cs @@ -1,6 +1,6 @@ using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.Pipeline; +using KitX.Core.Workflow.Conversion; using KitX.Core.Workflow.CFG; using KitX.Core.Workflow.BlockScripting; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PluginCallFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PluginCallFunction.cs index 4baa652e..f9aad10e 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PluginCallFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PluginCallFunction.cs @@ -1,7 +1,7 @@ using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; using Serilog; -using KitX.Core.Workflow.Pipeline; +using KitX.Core.Workflow.Conversion; using KitX.Core.Workflow.CFG; using KitX.Core.Workflow.BlockScripting; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PluginCallWithTargetFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PluginCallWithTargetFunction.cs index 415eb40e..2545c687 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PluginCallWithTargetFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PluginCallWithTargetFunction.cs @@ -1,7 +1,7 @@ using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; using Serilog; -using KitX.Core.Workflow.Pipeline; +using KitX.Core.Workflow.Conversion; using KitX.Core.Workflow.CFG; using KitX.Core.Workflow.BlockScripting; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PrintFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PrintFunction.cs index a192ed6d..cd884437 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PrintFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PrintFunction.cs @@ -1,6 +1,6 @@ using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.Pipeline; +using KitX.Core.Workflow.Conversion; using KitX.Core.Workflow.CFG; using KitX.Core.Workflow.BlockScripting; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/ReadTextFileFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/ReadTextFileFunction.cs index 780ae264..fc033616 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/ReadTextFileFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/ReadTextFileFunction.cs @@ -1,7 +1,7 @@ using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; using Serilog; -using KitX.Core.Workflow.Pipeline; +using KitX.Core.Workflow.Conversion; using KitX.Core.Workflow.CFG; using KitX.Core.Workflow.BlockScripting; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/RunWorkflowFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/RunWorkflowFunction.cs index 6280c735..97188494 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/RunWorkflowFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/RunWorkflowFunction.cs @@ -1,7 +1,7 @@ using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; using Serilog; -using KitX.Core.Workflow.Pipeline; +using KitX.Core.Workflow.Conversion; using KitX.Core.Workflow.CFG; using KitX.Core.Workflow.BlockScripting; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/SetFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/SetFunction.cs index 4cb4e437..ceee3150 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/SetFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/SetFunction.cs @@ -1,6 +1,6 @@ using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.Pipeline; +using KitX.Core.Workflow.Conversion; using KitX.Core.Workflow.CFG; using KitX.Core.Workflow.BlockScripting; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/StartPluginFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/StartPluginFunction.cs index 32cd16ec..6a3e26fb 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/StartPluginFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/StartPluginFunction.cs @@ -2,7 +2,7 @@ using KitX.Core.Contract.Plugin; using KitX.Core.Contract.Workflow; using Serilog; -using KitX.Core.Workflow.Pipeline; +using KitX.Core.Workflow.Conversion; using KitX.Core.Workflow.CFG; using KitX.Core.Workflow.BlockScripting; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/StopPluginFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/StopPluginFunction.cs index 4d95fd15..e884bbb1 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/StopPluginFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/StopPluginFunction.cs @@ -2,7 +2,7 @@ using KitX.Core.Contract.Plugin; using KitX.Core.Contract.Workflow; using Serilog; -using KitX.Core.Workflow.Pipeline; +using KitX.Core.Workflow.Conversion; using KitX.Core.Workflow.CFG; using KitX.Core.Workflow.BlockScripting; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/StopWorkflowFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/StopWorkflowFunction.cs index 8aec7178..e4919037 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/StopWorkflowFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/StopWorkflowFunction.cs @@ -1,7 +1,7 @@ using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; using Serilog; -using KitX.Core.Workflow.Pipeline; +using KitX.Core.Workflow.Conversion; using KitX.Core.Workflow.CFG; using KitX.Core.Workflow.BlockScripting; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/ToLoopCondFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/ToLoopCondFunction.cs index 7ef2be43..9aaee405 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/ToLoopCondFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/ToLoopCondFunction.cs @@ -1,6 +1,6 @@ using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.Pipeline; +using KitX.Core.Workflow.Conversion; using KitX.Core.Workflow.CFG; using KitX.Core.Workflow.BlockScripting; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/TryGetDeviceFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/TryGetDeviceFunction.cs index c59eff23..160555c0 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/TryGetDeviceFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/TryGetDeviceFunction.cs @@ -3,7 +3,7 @@ using KitX.Core.Contract.Device; using KitX.Shared.CSharp.Device; using Serilog; -using KitX.Core.Workflow.Pipeline; +using KitX.Core.Workflow.Conversion; using KitX.Core.Workflow.CFG; using KitX.Core.Workflow.BlockScripting; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/WriteTextFileFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/WriteTextFileFunction.cs index 3ca57189..6450045b 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/WriteTextFileFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/WriteTextFileFunction.cs @@ -1,7 +1,7 @@ using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; using Serilog; -using KitX.Core.Workflow.Pipeline; +using KitX.Core.Workflow.Conversion; using KitX.Core.Workflow.CFG; using KitX.Core.Workflow.BlockScripting; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/CFG/CFGBuilderFromScript.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/CFG/CFGBuilderFromScript.cs deleted file mode 100644 index 4aec370b..00000000 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/CFG/CFGBuilderFromScript.cs +++ /dev/null @@ -1,277 +0,0 @@ -using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.BlockScripting; -using KitX.Core.Workflow.Pipeline; -using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Blocks; -using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Pins; - -namespace KitX.Core.Workflow.CFG; - -/// -/// Builds a from a AST. -/// This is the canonical transformation for the BS→BP pipeline, replacing the -/// ad-hoc intermediate. -/// -/// Strategy: uses the existing for expression -/// expansion, then converts the to a -/// with typed edges. -/// -internal class CFGBuilderFromScript -{ - private readonly List _helperFunctions; - private readonly BuiltinFunctionRegistry? _functionRegistry; - - public CFGBuilderFromScript(List helperFunctions, BuiltinFunctionRegistry? functionRegistry = null) - { - _helperFunctions = helperFunctions; - _functionRegistry = functionRegistry; - } - - /// - /// Builds a ControlFlowGraph from a parsed BlockScript. - /// The BS2CFGConverter handles expression expansion; this method adds - /// CFG structure (blocks, edges, block types) and type classification. - /// - public ControlFlowGraph Build(BlockScript script, PipelineContext context) - { - // Phase 1: Use BS2CFGConverter for expression expansion - var formatter = new Pipeline.BS2CFGConverter(_helperFunctions, _functionRegistry); - var formatted = formatter.Format(script, context); - - // Phase 2: Convert ControlFlowGraph to ControlFlowGraph - var cfg = new ControlFlowGraph - { - HelperFunctions = _helperFunctions ?? [], - PubVarDeclarations = context.PubVarNames.ToList(), - }; - - // Convert ConstBlock variables - if (script.ConstBlock != null) - { - foreach (var varDecl in script.ConstBlock.Variables) - { - cfg.ConstDeclarations.Add(new ConstDeclaration - { - Name = varDecl.Name, - Type = varDecl.Type, - InitialValueExpression = varDecl.InitialValueExpression, - DefaultValue = varDecl.DefaultValue - }); - } - } - - // Convert PubVarBlock variables - if (script.PubVarBlock != null) - { - foreach (var varDecl in script.PubVarBlock.Variables) - { - if (!cfg.PubVarDeclarations.Contains(varDecl.Name)) - cfg.PubVarDeclarations.Add(varDecl.Name); - } - } - - // Convert formatted blocks to CFG blocks - var blockMap = new Dictionary(); - - foreach (var fmtBlock in formatted.Blocks) - { - var cfgBlock = new CFGBlock - { - Name = fmtBlock.Name, - Type = fmtBlock.Name == MainBlock ? CFGBlockType.Entry : ClassifyBlockType(fmtBlock), - NextBlockName = fmtBlock.NextBlockName, - }; - - // Convert statements - foreach (var fmtStmt in fmtBlock.Statements) - { - cfgBlock.Statements.Add(ConvertStatement(fmtStmt)); - } - - // Build edges from control flow statements - BuildEdgesFromBlock(cfgBlock, fmtBlock); - - cfg.Blocks.Add(cfgBlock); - blockMap[cfgBlock.Name] = cfgBlock; - - if (cfgBlock.IsMainBlock) - cfg.EntryBlock = cfgBlock; - } - - // Set EntryBlock if not set - if (cfg.EntryBlock == null && cfg.Blocks.Count > 0) - cfg.EntryBlock = cfg.Blocks[0]; - - // Set parent loop block names for ToLoopCond blocks - SetParentLoopReferences(cfg, script); - - // Transfer PubVar counter - cfg.PubVarCounter = context.NextPubVarCounter; - - return cfg; - } - - // ─── Statement Conversion ────────────────────────────────────────── - - private static CFGStatement ConvertStatement(CFGStatement fmtStmt) - { - return new CFGStatement - { - StatementId = fmtStmt.StatementId, - BlockName = fmtStmt.BlockName, - Kind = fmtStmt.Kind, - OriginalExpression = fmtStmt.OriginalExpression, - SourceLine = fmtStmt.SourceLine, - PubVarTarget = fmtStmt.PubVarTarget, - FunctionName = fmtStmt.FunctionName, - FullFunctionName = fmtStmt.FullFunctionName, - Arguments = fmtStmt.Arguments, - ConditionExpression = fmtStmt.ConditionExpression, - ConditionPubVar = fmtStmt.ConditionPubVar, - TrueBlockName = fmtStmt.TrueBlockName, - FalseBlockName = fmtStmt.FalseBlockName, - ToLoopCondReturnTo = fmtStmt.ToLoopCondReturnTo, - SetVarName = fmtStmt.SetVarName, - GetVarName = fmtStmt.GetVarName, - IsLoopConditionDuplication = fmtStmt.IsLoopConditionDuplication, - Fingerprint = fmtStmt.Fingerprint, - }; - } - - // ─── Block Type Classification ────────────────────────────────────── - - private static CFGBlockType ClassifyBlockType(CFGBlock fmtBlock) - { - if (fmtBlock.Name == MainBlock) - return CFGBlockType.Entry; - - // Classify based on the last statement - if (fmtBlock.Statements.Count == 0) - return CFGBlockType.Basic; - - var lastStmt = fmtBlock.Statements[^1]; - return lastStmt.Kind switch - { - CFGStatementKind.Branch => CFGBlockType.BranchHeader, - CFGStatementKind.Loop => CFGBlockType.LoopHeader, - _ => CFGBlockType.Basic - }; - } - - // ─── Edge Building ───────────────────────────────────────────────── - - private static void BuildEdgesFromBlock(CFGBlock cfgBlock, CFGBlock fmtBlock) - { - // Sequential fall-through edge (NextBlock) - if (!string.IsNullOrEmpty(fmtBlock.NextBlockName) && !cfgBlock.EndsWithControlFlow) - { - cfgBlock.Successors.Add(new CFGEdge - { - FromBlockName = cfgBlock.Name, - ToBlockName = fmtBlock.NextBlockName!, - Type = CFGEdgeType.Sequential, - PinName = Exec - }); - } - - // Edges from control flow statements - foreach (var stmt in fmtBlock.Statements) - { - switch (stmt.Kind) - { - case CFGStatementKind.Branch: - if (!string.IsNullOrEmpty(stmt.TrueBlockName)) - cfgBlock.Successors.Add(new CFGEdge - { - FromBlockName = cfgBlock.Name, - ToBlockName = stmt.TrueBlockName, - Type = CFGEdgeType.BranchTrue, - PinName = True - }); - if (!string.IsNullOrEmpty(stmt.FalseBlockName)) - cfgBlock.Successors.Add(new CFGEdge - { - FromBlockName = cfgBlock.Name, - ToBlockName = stmt.FalseBlockName, - Type = CFGEdgeType.BranchFalse, - PinName = False - }); - break; - - case CFGStatementKind.Loop: - if (!string.IsNullOrEmpty(stmt.TrueBlockName)) - cfgBlock.Successors.Add(new CFGEdge - { - FromBlockName = cfgBlock.Name, - ToBlockName = stmt.TrueBlockName, - Type = CFGEdgeType.LoopBody, - PinName = LoopBody - }); - if (!string.IsNullOrEmpty(stmt.FalseBlockName)) - cfgBlock.Successors.Add(new CFGEdge - { - FromBlockName = cfgBlock.Name, - ToBlockName = stmt.FalseBlockName, - Type = CFGEdgeType.LoopExit, - PinName = LoopEnd - }); - break; - - case CFGStatementKind.ToLoopCond: - if (!string.IsNullOrEmpty(stmt.ToLoopCondReturnTo)) - cfgBlock.Successors.Add(new CFGEdge - { - FromBlockName = cfgBlock.Name, - ToBlockName = stmt.ToLoopCondReturnTo, - Type = CFGEdgeType.LoopbackToCondition, - PinName = null // ToLoopCond doesn't map to a specific pin - }); - break; - - case CFGStatementKind.Break: - // Break edges are resolved later during BP→BS conversion - cfgBlock.Successors.Add(new CFGEdge - { - FromBlockName = cfgBlock.Name, - ToBlockName = "__break__", // Placeholder, resolved during conversion - Type = CFGEdgeType.Break, - PinName = null - }); - break; - } - } - } - - // ─── Parent Loop References ──────────────────────────────────────── - - /// - /// Sets for blocks that are loop bodies. - /// This is derived from the original BlockScript's ToLoopCond statements, - /// which reference their parent loop condition block. - /// - private static void SetParentLoopReferences(ControlFlowGraph cfg, BlockScript script) - { - // Build a map: block containing ToLoopCond → target block name - var toLoopCondTargets = new Dictionary(); - - foreach (var block in cfg.Blocks) - { - foreach (var stmt in block.Statements) - { - if (stmt.Kind == CFGStatementKind.ToLoopCond && !string.IsNullOrEmpty(stmt.ToLoopCondReturnTo)) - { - toLoopCondTargets[block.Name] = stmt.ToLoopCondReturnTo; - } - } - } - - // For each block that has a ToLoopCond edge, set its ParentLoopBlockName - foreach (var block in cfg.Blocks) - { - var toLoopCondEdge = block.Successors.FirstOrDefault(e => e.Type == CFGEdgeType.LoopbackToCondition); - if (toLoopCondEdge != null) - { - block.ParentLoopBlockName = toLoopCondEdge.ToBlockName; - } - } - } -} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/CFG/BP2CFGConverter.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/BP2CFGConverter.cs similarity index 99% rename from KitX Clients/KitX Core/KitX.Core/Workflow/CFG/BP2CFGConverter.cs rename to KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/BP2CFGConverter.cs index 5cfaf14c..191a6d3a 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/CFG/BP2CFGConverter.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/BP2CFGConverter.cs @@ -5,9 +5,10 @@ using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Pins; using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Blocks; -using KitX.Core.Workflow.Pipeline; -namespace KitX.Core.Workflow.CFG; +using KitX.Core.Workflow.CFG; +using KitX.Core.Workflow.BlockScripting; +namespace KitX.Core.Workflow.Conversion; /// /// Builds a from a . diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Pipeline/BS2CFGConverter.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/BS2CFGConverter.cs similarity index 99% rename from KitX Clients/KitX Core/KitX.Core/Workflow/Pipeline/BS2CFGConverter.cs rename to KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/BS2CFGConverter.cs index 4bfcb860..f2d49e6f 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Pipeline/BS2CFGConverter.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/BS2CFGConverter.cs @@ -6,7 +6,8 @@ using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Functions; using KitX.Core.Workflow.CFG; -namespace KitX.Core.Workflow.Pipeline; +using KitX.Core.Workflow.Blueprint; +namespace KitX.Core.Workflow.Conversion; /// /// Phase 2: Takes a parsed BlockScript AST and produces a ControlFlowGraph diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/CFG/BlockScriptSerializer.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/BlockScriptSerializer.cs similarity index 96% rename from KitX Clients/KitX Core/KitX.Core/Workflow/CFG/BlockScriptSerializer.cs rename to KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/BlockScriptSerializer.cs index 85e02415..2a35abf0 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/CFG/BlockScriptSerializer.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/BlockScriptSerializer.cs @@ -2,7 +2,10 @@ using KitX.Core.Contract.Workflow; using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Blocks; -namespace KitX.Core.Workflow.CFG; +using KitX.Core.Workflow.CFG; +using KitX.Core.Workflow.BlockScripting; +using KitX.Core.Workflow.Blueprint; +namespace KitX.Core.Workflow.Conversion; /// /// Serializes a to source code string. diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlockScriptToBlueprintConverter.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/BlockScriptToBlueprintConverter.cs similarity index 98% rename from KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlockScriptToBlueprintConverter.cs rename to KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/BlockScriptToBlueprintConverter.cs index 47b21834..cabce376 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlockScriptToBlueprintConverter.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/BlockScriptToBlueprintConverter.cs @@ -1,10 +1,10 @@ using KitX.Core.Contract.Workflow; using KitX.Core.Workflow.BlockScripting; using KitX.Core.Workflow.CFG; -using KitX.Core.Workflow.Pipeline; using Serilog; -namespace KitX.Core.Workflow.Blueprint; +using KitX.Core.Workflow.Blueprint; +namespace KitX.Core.Workflow.Conversion; /// /// Converts BlockScript to Blueprint via a clean 6-phase pipeline. diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintToBlockScriptConverter.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/BlueprintToBlockScriptConverter.cs similarity index 96% rename from KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintToBlockScriptConverter.cs rename to KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/BlueprintToBlockScriptConverter.cs index 7502724a..aa92c037 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintToBlockScriptConverter.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/BlueprintToBlockScriptConverter.cs @@ -2,7 +2,9 @@ using KitX.Core.Workflow.CFG; using Serilog; -namespace KitX.Core.Workflow.Blueprint; +using KitX.Core.Workflow.BlockScripting; +using KitX.Core.Workflow.Blueprint; +namespace KitX.Core.Workflow.Conversion; /// /// Converts Blueprint back to a fully-expanded BlockScript source code. diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BuiltinFunctionExportStrategyAdapter.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/BuiltinFunctionExportStrategyAdapter.cs similarity index 94% rename from KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BuiltinFunctionExportStrategyAdapter.cs rename to KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/BuiltinFunctionExportStrategyAdapter.cs index 7eea90d2..88916dc7 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BuiltinFunctionExportStrategyAdapter.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/BuiltinFunctionExportStrategyAdapter.cs @@ -2,7 +2,8 @@ using KitX.Core.Workflow.BlockScripting; using KitX.Core.Workflow.CFG; -namespace KitX.Core.Workflow.Blueprint; +using KitX.Core.Workflow.Blueprint; +namespace KitX.Core.Workflow.Conversion; /// /// 将 适配为 , diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Pipeline/CFG2BPConverter.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/CFG2BPConverter.cs similarity index 99% rename from KitX Clients/KitX Core/KitX.Core/Workflow/Pipeline/CFG2BPConverter.cs rename to KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/CFG2BPConverter.cs index e3160b72..981d14ce 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Pipeline/CFG2BPConverter.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/CFG2BPConverter.cs @@ -6,7 +6,8 @@ using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Functions; using KitX.Core.Workflow.CFG; -namespace KitX.Core.Workflow.Pipeline; +using KitX.Core.Workflow.Blueprint; +namespace KitX.Core.Workflow.Conversion; /// /// Phase 3: Creates all Blueprint nodes and exec flow edges from ControlFlowGraph. @@ -148,7 +149,6 @@ private void ProcessBlock(CFGBlock block, PipelineContext context) { case CFGStatementKind.Assignment: case CFGStatementKind.Expression: - case CFGStatementKind.PluginCallWithTarget: return ProcessCallOrAssignment(stmt, context, ref prevNode, ref prevStmtId); default: diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/CFG/CFG2BSConverter.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/CFG2BSConverter.cs similarity index 97% rename from KitX Clients/KitX Core/KitX.Core/Workflow/CFG/CFG2BSConverter.cs rename to KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/CFG2BSConverter.cs index 9fedd235..01f7dbae 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/CFG/CFG2BSConverter.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/CFG2BSConverter.cs @@ -1,7 +1,10 @@ using KitX.Core.Contract.Workflow; using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Blocks; -namespace KitX.Core.Workflow.CFG; +using KitX.Core.Workflow.CFG; +using KitX.Core.Workflow.BlockScripting; +using KitX.Core.Workflow.Blueprint; +namespace KitX.Core.Workflow.Conversion; /// /// Generates a from a . diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/CFG2CSGenerator.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/CFG2CSGenerator.cs similarity index 99% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/CFG2CSGenerator.cs rename to KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/CFG2CSGenerator.cs index 140684f0..a548e67a 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/CFG2CSGenerator.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/CFG2CSGenerator.cs @@ -5,10 +5,11 @@ using Serilog; using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; -using KitX.Core.Workflow.Pipeline; using KitX.Core.Workflow.CFG; -namespace KitX.Core.Workflow.BlockScripting; +using KitX.Core.Workflow.BlockScripting; +using KitX.Core.Workflow.Blueprint; +namespace KitX.Core.Workflow.Conversion; /// /// Generates Roslyn from a . diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/CFG/CFGConditionDuplicator.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/CFGConditionDuplicator.cs similarity index 100% rename from KitX Clients/KitX Core/KitX.Core/Workflow/CFG/CFGConditionDuplicator.cs rename to KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/CFGConditionDuplicator.cs diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/CFG/CFGPipeline.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/CFGPipeline.cs similarity index 98% rename from KitX Clients/KitX Core/KitX.Core/Workflow/CFG/CFGPipeline.cs rename to KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/CFGPipeline.cs index dca153a0..d95660f3 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/CFG/CFGPipeline.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/CFGPipeline.cs @@ -1,9 +1,9 @@ using KitX.Core.Contract.Workflow; using KitX.Core.Workflow.BlockScripting; using KitX.Core.Workflow.Blueprint; -using KitX.Core.Workflow.Pipeline; -namespace KitX.Core.Workflow.CFG; +using KitX.Core.Workflow.CFG; +namespace KitX.Core.Workflow.Conversion; /// /// Canonical CFG pipeline: five sub-path functions that compose into four main paths. diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ConversionContext.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/ConversionContext.cs similarity index 94% rename from KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ConversionContext.cs rename to KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/ConversionContext.cs index a4651fd9..5f50b107 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/ConversionContext.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/ConversionContext.cs @@ -1,6 +1,9 @@ using KitX.Core.Contract.Workflow; -namespace KitX.Core.Workflow.Blueprint; +using KitX.Core.Workflow.CFG; +using KitX.Core.Workflow.BlockScripting; +using KitX.Core.Workflow.Blueprint; +namespace KitX.Core.Workflow.Conversion; /// /// Accumulated state for Blueprint → BlockScript conversion. diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Pipeline/DataEdgeBuilder.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/DataEdgeBuilder.cs similarity index 99% rename from KitX Clients/KitX Core/KitX.Core/Workflow/Pipeline/DataEdgeBuilder.cs rename to KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/DataEdgeBuilder.cs index eb5e5477..dd4bc999 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Pipeline/DataEdgeBuilder.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/DataEdgeBuilder.cs @@ -5,7 +5,8 @@ using Serilog; using KitX.Core.Workflow.CFG; -namespace KitX.Core.Workflow.Pipeline; +using KitX.Core.Workflow.Blueprint; +namespace KitX.Core.Workflow.Conversion; /// /// Phase 4+5: Creates data edges from ControlFlowGraph argument analysis. diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Pipeline/ExprUtils.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/ExprUtils.cs similarity index 97% rename from KitX Clients/KitX Core/KitX.Core/Workflow/Pipeline/ExprUtils.cs rename to KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/ExprUtils.cs index fe6b2b74..5598b07d 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Pipeline/ExprUtils.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/ExprUtils.cs @@ -2,7 +2,10 @@ using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; -namespace KitX.Core.Workflow.Pipeline; +using KitX.Core.Workflow.CFG; +using KitX.Core.Workflow.BlockScripting; +using KitX.Core.Workflow.Blueprint; +namespace KitX.Core.Workflow.Conversion; /// /// Shared expression-parsing utilities used across pipeline phases. diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Pipeline/PipelineAssembler.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/PipelineAssembler.cs similarity index 98% rename from KitX Clients/KitX Core/KitX.Core/Workflow/Pipeline/PipelineAssembler.cs rename to KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/PipelineAssembler.cs index 4b8d5258..cde52f1c 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Pipeline/PipelineAssembler.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/PipelineAssembler.cs @@ -1,7 +1,10 @@ using KitX.Core.Contract.Workflow; using Serilog; -namespace KitX.Core.Workflow.Pipeline; +using KitX.Core.Workflow.CFG; +using KitX.Core.Workflow.BlockScripting; +using KitX.Core.Workflow.Blueprint; +namespace KitX.Core.Workflow.Conversion; /// /// Phase 6: Assembles the final Blueprint from all pipeline outputs. diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Pipeline/PipelineContext.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/PipelineContext.cs similarity index 98% rename from KitX Clients/KitX Core/KitX.Core/Workflow/Pipeline/PipelineContext.cs rename to KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/PipelineContext.cs index 8b6cd623..b9b83e69 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Pipeline/PipelineContext.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/PipelineContext.cs @@ -2,7 +2,9 @@ using KitX.Core.Contract.Workflow; using KitX.Core.Workflow.CFG; -namespace KitX.Core.Workflow.Pipeline; +using KitX.Core.Workflow.BlockScripting; +using KitX.Core.Workflow.Blueprint; +namespace KitX.Core.Workflow.Conversion; /// /// Accumulated state flowing through all 6 conversion phases. From 25fbabd5aabca345d9920b55b7e9b0f9bf614e04 Mon Sep 17 00:00:00 2001 From: StarInk Date: Sun, 14 Jun 2026 15:41:26 +0200 Subject: [PATCH 076/110] =?UTF-8?q?=F0=9F=A7=A9=20Refactor(Workflow):=20CF?= =?UTF-8?q?G2CS=20=E6=B4=BE=E5=8F=91=E5=BD=92=E4=B8=80=E5=88=B0=E5=86=85?= =?UTF-8?q?=E7=BD=AE=E5=87=BD=E6=95=B0=E6=8F=8F=E8=BF=B0=E7=AC=A6=E3=80=81?= =?UTF-8?q?=E6=B6=88=E7=81=AD=E7=A1=AC=E7=BC=96=E7=A0=81=E5=88=86=E6=94=AF?= =?UTF-8?q?-=E6=9C=80=E7=BB=88=E9=87=8D=E6=9E=84=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E9=98=B6=E6=AE=B51?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Workflow/BlockScripting/CSEmitContext.cs | 99 +++++ .../IBuiltinFunctionDefinition.cs | 13 + .../BuiltinFunctions/BranchFunction.cs | 9 + .../BuiltinFunctions/BreakFunction.cs | 3 + .../Workflow/BuiltinFunctions/FlipFunction.cs | 11 + .../Workflow/BuiltinFunctions/GetFunction.cs | 6 + .../Workflow/BuiltinFunctions/LoopFunction.cs | 9 + .../BuiltinFunctions/PauseFunction.cs | 6 + .../BuiltinFunctions/PluginCallFunction.cs | 3 + .../PluginCallWithTargetFunction.cs | 3 + .../BuiltinFunctions/PrintFunction.cs | 6 + .../Workflow/BuiltinFunctions/SetFunction.cs | 11 + .../BuiltinFunctions/ToLoopCondFunction.cs | 3 + .../BuiltinFunctions/TryGetDeviceFunction.cs | 7 + .../Workflow/Conversion/CFG2CSGenerator.cs | 345 +++++------------- 15 files changed, 274 insertions(+), 260 deletions(-) create mode 100644 KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/CSEmitContext.cs diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/CSEmitContext.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/CSEmitContext.cs new file mode 100644 index 00000000..18505b8a --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/CSEmitContext.cs @@ -0,0 +1,99 @@ +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; +using KitX.Core.Workflow.CFG; +using KitX.Core.Workflow.Conversion; + +namespace KitX.Core.Workflow.BlockScripting; + +/// +/// Context passed to during +/// CFG→C# generation. Holds the per-script type map and shared emission helpers so each +/// builtin descriptor can emit its C# without reimplementing boilerplate, and so +/// dispatches without hardcoding function names. +/// +public sealed class CSEmitContext +{ + /// Inferred C# type name per PubVar/Const identifier. + public Dictionary PubVarTypes { get; } + + /// Return type per helper function name. + public Dictionary HelperReturnTypes { get; } + + public CSEmitContext(Dictionary pubVarTypes, Dictionary helperReturnTypes) + { + PubVarTypes = pubVarTypes; + HelperReturnTypes = helperReturnTypes; + } + + // ─── Primitive builders ─────────────────────────── + + /// A string literal expression (quoted in generated source). + public ExpressionSyntax Literal(string value) + => LiteralExpression(SyntaxKind.StringLiteralExpression, SyntaxFactory.Literal(value)); + + /// A null literal expression. + public ExpressionSyntax NullLiteral() + => LiteralExpression(SyntaxKind.NullLiteralExpression); + + /// Resolves a formatted argument string into a Roslyn expression. + public ExpressionSyntax ResolveArgument(string arg) + => CFG2CSGenerator.ResolveArgumentExpression(arg, PubVarTypes); + + /// Wraps an expression in ConvertTo<T>(...) for typed PubVar assignment. + public ExpressionSyntax ConvertTo(string typeName, ExpressionSyntax expr) + => CFG2CSGenerator.BuildConvertToInvocation(typeName, expr); + + /// Builds a G.member(args...) invocation expression. + public InvocationExpressionSyntax GInvoke(string member, params ExpressionSyntax[] args) + => CFG2CSGenerator.BuildGInvoke(member, args); + + /// Builds a G.member(args...) expression statement. + public StatementSyntax GInvokeStatement(string member, params ExpressionSyntax[] args) + => ExpressionStatement(GInvoke(member, args)); + + // ─── Statement assemblers ───────────────────────── + + /// + /// Emits the value-assignment statements for a value-producing expression: + /// typed local declaration plus G.Set sync (when assigned to a PubVar), + /// or a bare expression statement otherwise. + /// + public List EmitValueAssignment(string? pubVarTarget, ExpressionSyntax rhs, string sourceType = "object") + => CFG2CSGenerator.BuildValueAssignment(pubVarTarget, rhs, sourceType, PubVarTypes); + + /// Emits var pubVarTarget = init; (inferred-type local declaration). + public List EmitVarLocal(string pubVarTarget, ExpressionSyntax init) + => new() { + LocalDeclarationStatement( + VariableDeclaration(IdentifierName("var")) + .AddVariables(VariableDeclarator(Identifier(pubVarTarget)) + .WithInitializer(EqualsValueClause(init)))) + }; + + /// Parses a C# expression from source text. + public ExpressionSyntax Parse(string code) => ParseExpression(code); + + /// A return; statement. + public StatementSyntax Return() => ReturnStatement(); + + /// + /// Emits the shared flow-control form: G.NextBlock = G.{member}(args); break;. + /// Used by Branch/Loop/ToLoopCond/Flip descriptors. + /// + public List EmitNextBlockAssignment(string member, params ExpressionSyntax[] args) + => new() { + ExpressionStatement(AssignmentExpression(SyntaxKind.SimpleAssignmentExpression, + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("G"), IdentifierName("NextBlock")), + GInvoke(member, args))), + BreakStatement() + }; + + /// + /// Default emission for a registered builtin with no custom override: + /// G.{Name}(args) (or bare {Name}(args) for helpers) plus value assignment. + /// + public List EmitDefault(CFGStatement stmt) + => CFG2CSGenerator.EmitDefaultStatements(stmt, this); +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/IBuiltinFunctionDefinition.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/IBuiltinFunctionDefinition.cs index 340cb2d1..e7d5c272 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/IBuiltinFunctionDefinition.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/IBuiltinFunctionDefinition.cs @@ -98,6 +98,19 @@ List FormatInvocation( /// BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt); + // ─── CS 生成(CFGStatement → C#)──────────── + + /// + /// 按 生成 C# 语句(CFG→CS 阶段)。由 CFG2CSGenerator + /// 统一派发调用,使生成器无需针对具体函数名硬编码分支。 + /// 默认实现走通用 G.{FunctionName}(args) 形式 + 赋值包裹; + /// 需要自定义代码生成的函数覆写此方法。 + /// + /// 当前 CFG 语句 + /// CS 生成上下文(类型映射 + 共享辅助) + List EmitStatements(CFGStatement stmt, CSEmitContext ctx) + => ctx.EmitDefault(stmt); + // ─── 导出(Blueprint → BlockScript)──────────── /// 将蓝图节点转换回 BlockScript 语句 diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/BranchFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/BranchFunction.cs index b230fb24..9c7394c0 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/BranchFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/BranchFunction.cs @@ -71,6 +71,15 @@ public void OnNodeCreated(BlueprintNode node, CFGStatement stmt, PipelineContext } } + public List EmitStatements(CFGStatement stmt, CSEmitContext ctx) + { + var condExpr = !string.IsNullOrEmpty(stmt.ConditionPubVar) + ? ctx.ResolveArgument(stmt.ConditionPubVar) + : ctx.Parse(stmt.ConditionExpression ?? "false"); + return ctx.EmitNextBlockAssignment("Branch", condExpr, + ctx.Literal(stmt.TrueBlockName ?? ""), ctx.Literal(stmt.FalseBlockName ?? "")); + } + public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) { var condition = helper.GetInputValue(node, "Condition"); diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/BreakFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/BreakFunction.cs index a10def3a..f0b3a1e2 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/BreakFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/BreakFunction.cs @@ -29,6 +29,9 @@ public class BreakFunction : IBuiltinFunctionDefinition public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; + public List EmitStatements(CFGStatement stmt, CSEmitContext ctx) + => new() { ctx.Return() }; + public List FormatInvocation( InvocationExpressionSyntax invoke, string blockName, PipelineContext context, string? assignedVar) diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/FlipFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/FlipFunction.cs index eefaab76..1a135a13 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/FlipFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/FlipFunction.cs @@ -84,6 +84,17 @@ public void OnNodeCreated(BlueprintNode node, CFGStatement stmt, PipelineContext }); } + public List EmitStatements(CFGStatement stmt, CSEmitContext ctx) + { + // NOTE: Flip currently shares the Branch control-flow form (StatementKind=Branch), + // so it emits G.Branch(...) — a known alias to revisit. Not exercised by tests. + var condExpr = !string.IsNullOrEmpty(stmt.ConditionPubVar) + ? ctx.ResolveArgument(stmt.ConditionPubVar) + : ctx.Parse(stmt.ConditionExpression ?? "false"); + return ctx.EmitNextBlockAssignment("Branch", condExpr, + ctx.Literal(stmt.TrueBlockName ?? ""), ctx.Literal(stmt.FalseBlockName ?? "")); + } + public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) { return new ExpressionStatement diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/GetFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/GetFunction.cs index a2ab9da5..ee9b8b28 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/GetFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/GetFunction.cs @@ -33,6 +33,12 @@ public class GetFunction : IBuiltinFunctionDefinition public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; + public List EmitStatements(CFGStatement stmt, CSEmitContext ctx) + { + var varName = stmt.Arguments?.Count > 0 ? stmt.Arguments[0].Trim('"') : ""; + return ctx.EmitValueAssignment(stmt.PubVarTarget, CFG2CSGenerator.BuildGetInvocation(varName)); + } + public List FormatInvocation( InvocationExpressionSyntax invoke, string blockName, PipelineContext context, string? assignedVar) diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/LoopFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/LoopFunction.cs index 4e3bb7ed..ac2ae2a3 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/LoopFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/LoopFunction.cs @@ -75,6 +75,15 @@ public void OnNodeCreated(BlueprintNode node, CFGStatement stmt, PipelineContext context.LoopNodesByParent[stmt.BlockName] = node; } + public List EmitStatements(CFGStatement stmt, CSEmitContext ctx) + { + var condExpr = !string.IsNullOrEmpty(stmt.ConditionPubVar) + ? ctx.ResolveArgument(stmt.ConditionPubVar) + : ctx.Parse(stmt.ConditionExpression ?? "false"); + return ctx.EmitNextBlockAssignment("Loop", condExpr, + ctx.Literal(stmt.TrueBlockName ?? ""), ctx.Literal(stmt.FalseBlockName ?? "")); + } + public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) { var condition = helper.GetInputValue(node, "Condition"); diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PauseFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PauseFunction.cs index 70f0ae25..b40efedd 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PauseFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PauseFunction.cs @@ -30,6 +30,12 @@ public class PauseFunction : IBuiltinFunctionDefinition public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; + public List EmitStatements(CFGStatement stmt, CSEmitContext ctx) + { + if (stmt.Arguments.Count == 0) return new(); + return new() { ctx.GInvokeStatement(FunctionName, ctx.ResolveArgument(stmt.Arguments[0])) }; + } + public List FormatInvocation( InvocationExpressionSyntax invoke, string blockName, PipelineContext context, string? assignedVar) diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PluginCallFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PluginCallFunction.cs index f9aad10e..d8c1b7f6 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PluginCallFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PluginCallFunction.cs @@ -33,6 +33,9 @@ public class PluginCallFunction : IBuiltinFunctionDefinition public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; + public List EmitStatements(CFGStatement stmt, CSEmitContext ctx) + => ctx.EmitValueAssignment(stmt.PubVarTarget, CFG2CSGenerator.BuildPluginCallExpression(stmt, ctx.PubVarTypes)); + public List FormatInvocation( InvocationExpressionSyntax invoke, string blockName, PipelineContext context, string? assignedVar) diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PluginCallWithTargetFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PluginCallWithTargetFunction.cs index 2545c687..a36e1a55 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PluginCallWithTargetFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PluginCallWithTargetFunction.cs @@ -36,6 +36,9 @@ public class PluginCallWithTargetFunction : IBuiltinFunctionDefinition public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; + public List EmitStatements(CFGStatement stmt, CSEmitContext ctx) + => ctx.EmitValueAssignment(stmt.PubVarTarget, CFG2CSGenerator.BuildPluginCallWithTargetExpression(stmt, ctx.PubVarTypes)); + public List FormatInvocation( InvocationExpressionSyntax invoke, string blockName, PipelineContext context, string? assignedVar) diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PrintFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PrintFunction.cs index cd884437..e0a3b351 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PrintFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PrintFunction.cs @@ -30,6 +30,12 @@ public class PrintFunction : IBuiltinFunctionDefinition public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; + public List EmitStatements(CFGStatement stmt, CSEmitContext ctx) + { + if (stmt.Arguments.Count == 0) return new(); + return new() { ctx.GInvokeStatement(FunctionName, ctx.ResolveArgument(stmt.Arguments[0])) }; + } + public List FormatInvocation( InvocationExpressionSyntax invoke, string blockName, PipelineContext context, string? assignedVar) diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/SetFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/SetFunction.cs index ceee3150..177b9bb0 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/SetFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/SetFunction.cs @@ -33,6 +33,17 @@ public class SetFunction : IBuiltinFunctionDefinition public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; + public List EmitStatements(CFGStatement stmt, CSEmitContext ctx) + { + var result = new List(); + var varName = stmt.Arguments?.Count > 0 ? stmt.Arguments[0].Trim('"') : ""; + if (stmt.Arguments.Count > 1) + result.Add(ctx.GInvokeStatement("Set", ctx.Literal(varName), ctx.ResolveArgument(stmt.Arguments[1]))); + else if (stmt.Arguments.Count > 0) + result.Add(ctx.GInvokeStatement("Set", ctx.Literal(varName), ctx.ResolveArgument(stmt.Arguments[0]))); + return result; + } + public List FormatInvocation( InvocationExpressionSyntax invoke, string blockName, PipelineContext context, string? assignedVar) diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/ToLoopCondFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/ToLoopCondFunction.cs index 9aaee405..49a1accd 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/ToLoopCondFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/ToLoopCondFunction.cs @@ -71,6 +71,9 @@ public void OnNodeCreated(BlueprintNode node, CFGStatement stmt, PipelineContext } } + public List EmitStatements(CFGStatement stmt, CSEmitContext ctx) + => ctx.EmitNextBlockAssignment("ToLoopCond", ctx.Literal(stmt.ToLoopCondReturnTo ?? "")); + public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) { // Trace exec output connection to find the target block name diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/TryGetDeviceFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/TryGetDeviceFunction.cs index 160555c0..9e2217a6 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/TryGetDeviceFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/TryGetDeviceFunction.cs @@ -37,6 +37,13 @@ public class TryGetDeviceFunction : IBuiltinFunctionDefinition public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; + public List EmitStatements(CFGStatement stmt, CSEmitContext ctx) + { + if (stmt.Arguments.Count > 0 && !string.IsNullOrEmpty(stmt.PubVarTarget)) + return ctx.EmitVarLocal(stmt.PubVarTarget, ctx.GInvoke("TryGetDevice", ctx.ResolveArgument(stmt.Arguments[0]))); + return new(); + } + public List FormatInvocation( InvocationExpressionSyntax invoke, string blockName, PipelineContext context, string? assignedVar) diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/CFG2CSGenerator.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/CFG2CSGenerator.cs index a548e67a..2a69a797 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/CFG2CSGenerator.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/CFG2CSGenerator.cs @@ -61,11 +61,7 @@ internal static Dictionary InferPubVarTypes( { if (stmt.PubVarTarget == null) continue; - if (stmt.FunctionName == "Get") - { - pubVarTypes[stmt.PubVarTarget] = "object"; - } - else if (helperMap.TryGetValue(stmt.FunctionName ?? "", out var helper)) + if (helperMap.TryGetValue(stmt.FunctionName ?? "", out var helper)) { pubVarTypes[stmt.PubVarTarget] = helper.ReturnType; } @@ -538,6 +534,7 @@ internal static SwitchSectionSyntax GenerateFormattedBlockCase( var hasNextBlockAssignment = false; var stmtIndex = 0; + var ctx = new CSEmitContext(pubVarTypes, helperReturnTypes); foreach (var stmt in block.Statements) { @@ -547,6 +544,16 @@ internal static SwitchSectionSyntax GenerateFormattedBlockCase( } stmtIndex++; + // Route ALL registered builtin functions (value, simple, and flow-control) through + // their descriptor — eliminates per-function-name hardcoding below. + var def = stmt.FunctionName != null ? FunctionRegistry.Get(stmt.FunctionName) : null; + if (def != null) + { + caseStatements.AddRange(def.EmitStatements(stmt, ctx)); + if (def.IsBlockTerminator) hasNextBlockAssignment = true; + continue; + } + switch (stmt.Kind) { case CFGStatementKind.Assignment: @@ -555,27 +562,9 @@ internal static SwitchSectionSyntax GenerateFormattedBlockCase( ExpressionSyntax rawExpr; string sourceType = "object"; - if (stmt.FunctionName == "Get") - { - var varName = stmt.Arguments?.Count > 0 ? stmt.Arguments[0].Trim('"') : ""; - rawExpr = BuildGetInvocation(varName); - } - else if (stmt.FunctionName == "Set") - { - var varName = stmt.Arguments?.Count > 0 ? stmt.Arguments[0].Trim('"') : ""; - var valueExpr = stmt.Arguments?.Count > 1 - ? ResolveArgumentExpression(stmt.Arguments[1], pubVarTypes) - : LiteralExpression(SyntaxKind.NullLiteralExpression); - rawExpr = InvocationExpression( - MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, - IdentifierName("G"), IdentifierName("Set")), - ArgumentList(SeparatedList(new[] - { - Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(varName))), - Argument(valueExpr) - }))); - } - else if (IsHelperFunction(stmt.FunctionName, helperFunctions)) + // Registered builtins are dispatched above via their descriptor; here only + // helper functions and unregistered calls (bare invocation fallback) remain. + if (IsHelperFunction(stmt.FunctionName, helperFunctions)) { var args = stmt.Arguments.Select(a => Argument(ResolveArgumentExpression(a, pubVarTypes))).ToList(); @@ -584,189 +573,15 @@ internal static SwitchSectionSyntax GenerateFormattedBlockCase( sourceType = helperReturnTypes.TryGetValue(stmt.FunctionName ?? "", out var rt) ? rt : "object"; } - else if (stmt.FunctionName == "PluginCallWithTarget") - { - rawExpr = BuildPluginCallWithTargetExpression(stmt, pubVarTypes); - } - else if (stmt.FullFunctionName != null && stmt.FullFunctionName.Contains('.')) - { - rawExpr = BuildPluginCallExpression(stmt, pubVarTypes); - } - else if (FunctionRegistry.AllFunctionNames.Contains(stmt.FunctionName ?? "")) - { - rawExpr = ParseExpression( - $"G.{stmt.FunctionName}({string.Join(", ", stmt.Arguments)})"); - } else { rawExpr = ParseExpression( $"{stmt.FunctionName}({string.Join(", ", stmt.Arguments)})"); } - if (stmt.PubVarTarget != null) - { - var typeName = pubVarTypes.GetValueOrDefault(stmt.PubVarTarget, "object"); - ExpressionSyntax initExpr = (typeName != "object" && sourceType == "object") - ? BuildConvertToInvocation(typeName, rawExpr) - : rawExpr; - - caseStatements.Add(LocalDeclarationStatement( - VariableDeclaration(ParseTypeName(typeName)) - .AddVariables(VariableDeclarator(Identifier(stmt.PubVarTarget)) - .WithInitializer(EqualsValueClause(initExpr))))); - - // Sync PubVar to globals so debugger sees the value - caseStatements.Add(ExpressionStatement( - InvocationExpression( - MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, - IdentifierName("G"), IdentifierName("Set")), - ArgumentList(SeparatedList(new[] - { - Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, - Literal(stmt.PubVarTarget))), - Argument(IdentifierName(stmt.PubVarTarget)) - }))))); - } - else - { - caseStatements.Add(ExpressionStatement(rawExpr)); - } - - break; - } + caseStatements.AddRange( + BuildValueAssignment(stmt.PubVarTarget, rawExpr, sourceType, pubVarTypes)); - case CFGStatementKind.Branch: - { - GenerateFlowControl("Branch", stmt, caseStatements, pubVarTypes, - ref hasNextBlockAssignment, - conditionPubVar: stmt.ConditionPubVar, - trueBlockName: stmt.TrueBlockName, falseBlockName: stmt.FalseBlockName); - break; - } - - case CFGStatementKind.Loop: - { - GenerateFlowControl("Loop", stmt, caseStatements, pubVarTypes, - ref hasNextBlockAssignment, - conditionPubVar: stmt.ConditionPubVar, - trueBlockName: stmt.TrueBlockName, falseBlockName: stmt.FalseBlockName); - break; - } - - case CFGStatementKind.Print: - { - GenerateSimpleMethodCall("Print", stmt, caseStatements, pubVarTypes); - break; - } - - case CFGStatementKind.Pause: - { - GenerateSimpleMethodCall("Pause", stmt, caseStatements, pubVarTypes); - break; - } - - case CFGStatementKind.TryGetDevice: - { - if (stmt.Arguments.Count > 0 && !string.IsNullOrEmpty(stmt.PubVarTarget)) - { - var patternExpr = ResolveArgumentExpression(stmt.Arguments[0], pubVarTypes); - caseStatements.Add( - LocalDeclarationStatement( - VariableDeclaration(IdentifierName("var")) - .AddVariables( - VariableDeclarator(Identifier(stmt.PubVarTarget)) - .WithInitializer( - EqualsValueClause( - InvocationExpression( - MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, - IdentifierName("G"), IdentifierName("TryGetDevice")), - ArgumentList(SeparatedList(new[] { Argument(patternExpr) })))))))); - } - - break; - } - - case CFGStatementKind.Set: - { - var varName = stmt.Arguments?.Count > 0 ? stmt.Arguments[0].Trim('"') : ""; - if (stmt.Arguments.Count > 1) - { - var valueExpr = ResolveArgumentExpression(stmt.Arguments[1], pubVarTypes); - caseStatements.Add( - ExpressionStatement( - InvocationExpression( - MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, - IdentifierName("G"), IdentifierName("Set")), - ArgumentList(SeparatedList(new[] - { - Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(varName))), - Argument(valueExpr) - }))))); - } - else if (stmt.Arguments.Count > 0) - { - // Single-arg case (legacy): use first arg as value, varName might be empty - var valueExpr = ResolveArgumentExpression(stmt.Arguments[0], pubVarTypes); - caseStatements.Add( - ExpressionStatement( - InvocationExpression( - MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, - IdentifierName("G"), IdentifierName("Set")), - ArgumentList(SeparatedList(new[] - { - Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(varName))), - Argument(valueExpr) - }))))); - } - - break; - } - - case CFGStatementKind.ToLoopCond: - { - GenerateFlowControl("ToLoopCond", stmt, caseStatements, pubVarTypes, - ref hasNextBlockAssignment, - returnToBlock: stmt.ToLoopCondReturnTo); - break; - } - - case CFGStatementKind.PluginCallWithTarget: - { - var callExpr = BuildPluginCallWithTargetExpression(stmt, pubVarTypes); - if (stmt.PubVarTarget != null) - { - // G.PluginCallWithTarget returns object? — wrap in ConvertTo when - // assigning to a typed PubVar, mirroring the Assignment/Expression case. - var typeName = pubVarTypes.GetValueOrDefault(stmt.PubVarTarget, "object"); - ExpressionSyntax initExpr = typeName != "object" - ? BuildConvertToInvocation(typeName, callExpr) - : callExpr; - caseStatements.Add(LocalDeclarationStatement( - VariableDeclaration(ParseTypeName(typeName)) - .AddVariables(VariableDeclarator(Identifier(stmt.PubVarTarget)) - .WithInitializer(EqualsValueClause(initExpr))))); - - // Sync PubVar to globals so debugger sees the value - caseStatements.Add(ExpressionStatement( - InvocationExpression( - MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, - IdentifierName("G"), IdentifierName("Set")), - ArgumentList(SeparatedList(new[] - { - Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(stmt.PubVarTarget))), - Argument(IdentifierName(stmt.PubVarTarget)) - }))))); - } - else - { - caseStatements.Add(ExpressionStatement(callExpr)); - } - break; - } - - case CFGStatementKind.Break: - { - caseStatements.Add(ReturnStatement()); break; } @@ -809,6 +624,74 @@ internal static InvocationExpressionSyntax BuildConvertToInvocation(string typeN ArgumentList(SeparatedList(new[] { Argument(argExpr) }))); } + /// + /// Builds the C# statements for a value-producing expression: either a typed local + /// declaration plus a G.Set sync (when assigned to a PubVar), or a bare + /// expression statement. This is the shared assignment wrapping used by all + /// value-producing builtin functions. + /// + internal static List BuildValueAssignment( + string? pubVarTarget, ExpressionSyntax rawExpr, string sourceType, + Dictionary pubVarTypes) + { + var result = new List(); + + if (pubVarTarget != null) + { + var typeName = pubVarTypes.GetValueOrDefault(pubVarTarget, "object"); + ExpressionSyntax initExpr = (typeName != "object" && sourceType == "object") + ? BuildConvertToInvocation(typeName, rawExpr) + : rawExpr; + + result.Add(LocalDeclarationStatement( + VariableDeclaration(ParseTypeName(typeName)) + .AddVariables(VariableDeclarator(Identifier(pubVarTarget)) + .WithInitializer(EqualsValueClause(initExpr))))); + + // Sync PubVar to globals so debugger sees the value + result.Add(ExpressionStatement( + InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("G"), IdentifierName("Set")), + ArgumentList(SeparatedList(new[] + { + Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(pubVarTarget))), + Argument(IdentifierName(pubVarTarget)) + }))))); + } + else + { + result.Add(ExpressionStatement(rawExpr)); + } + + return result; + } + + /// + /// Builds a G.member(args...) invocation expression. + /// + internal static InvocationExpressionSyntax BuildGInvoke(string member, params ExpressionSyntax[] args) + { + return InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("G"), IdentifierName(member)), + ArgumentList(SeparatedList(args.Select(a => Argument(a))))); + } + + /// + /// Default emission for a registered builtin without a custom EmitStatements + /// override: emits G.{Name}(args) for registered builtins, or a bare + /// {Name}(args) for helper functions, then applies value assignment. + /// + internal static List EmitDefaultStatements(CFGStatement stmt, CSEmitContext ctx) + { + var name = stmt.FunctionName ?? ""; + var args = string.Join(", ", stmt.Arguments ?? new List()); + var isBuiltin = FunctionRegistry.AllFunctionNames.Contains(name); + var rawExpr = ParseExpression(isBuiltin ? $"G.{name}({args})" : $"{name}({args})"); + return BuildValueAssignment(stmt.PubVarTarget, rawExpr, "object", ctx.PubVarTypes); + } + /// /// Builds G.Get<object>("varName") expression. /// @@ -902,64 +785,6 @@ internal static bool IsHelperFunction(string? name, List? helper return helperFunctions.Any(h => h.Name == name); } - /// - /// Generates a simple G.Method(arg) call for single-argument globals methods like Print/Pause. - /// - private static void GenerateSimpleMethodCall(string methodName, CFGStatement stmt, - List caseStatements, Dictionary pubVarTypes) - { - if (stmt.Arguments.Count == 0) return; - var argExpr = ResolveArgumentExpression(stmt.Arguments[0], pubVarTypes); - caseStatements.Add(ExpressionStatement( - InvocationExpression( - MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, - IdentifierName("G"), IdentifierName(methodName)), - ArgumentList(SeparatedList(new[] { Argument(argExpr) }))))); - } - - /// - /// Generates flow control statements (G.NextBlock = G.Branch/G.Loop/G.ToLoopCond(...)). - /// For Branch/Loop: 3 arguments (condition, trueBlock, falseBlock). - /// For ToLoopCond: 1 argument (returnToBlock). - /// - private static void GenerateFlowControl(string methodName, CFGStatement stmt, - List caseStatements, Dictionary pubVarTypes, - ref bool hasNextBlockAssignment, string? conditionPubVar = null, - string? trueBlockName = null, string? falseBlockName = null, - string? returnToBlock = null) - { - var resolved = new List(); - - if (methodName == "ToLoopCond") - { - resolved.Add(Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, - Literal(returnToBlock ?? "")))); - } - else - { - var condExpr = !string.IsNullOrEmpty(conditionPubVar) - ? ResolveArgumentExpression(conditionPubVar, pubVarTypes) - : ParseExpression(stmt.ConditionExpression ?? "false"); - resolved.Add(Argument(condExpr)); - resolved.Add(Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, - Literal(trueBlockName ?? "")))); - resolved.Add(Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, - Literal(falseBlockName ?? "")))); - } - - caseStatements.Add(ExpressionStatement( - AssignmentExpression(SyntaxKind.SimpleAssignmentExpression, - MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, - IdentifierName("G"), IdentifierName("NextBlock")), - InvocationExpression( - MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, - IdentifierName("G"), IdentifierName(methodName)), - ArgumentList(SeparatedList(resolved)))))); - - hasNextBlockAssignment = true; - caseStatements.Add(BreakStatement()); - } - private static StatementSyntax GenerateDebugCheckpoint( string? statementId, string? blockName) { From 69c48bf29097a0a84a3dc899af198eb74a572e2f Mon Sep 17 00:00:00 2001 From: StarInk Date: Mon, 15 Jun 2026 06:25:01 +0200 Subject: [PATCH 077/110] =?UTF-8?q?=F0=9F=A7=A9=20Refactor(Workflow):=20?= =?UTF-8?q?=E5=90=88=E5=B9=B6=E6=A0=BC=E5=BC=8F=E5=8C=96=E5=99=A8=E9=92=A9?= =?UTF-8?q?=E5=AD=90=E4=B8=BA=E5=8D=95=E4=B8=80=20LowerToCFG=E3=80=81?= =?UTF-8?q?=E5=88=A0=E9=99=A4=20FormatInvocation/ExtractStatementFields-?= =?UTF-8?q?=E6=9C=80=E7=BB=88=E9=87=8D=E6=9E=84=E4=BC=98=E5=8C=96=E9=98=B6?= =?UTF-8?q?=E6=AE=B52?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../IBuiltinFunctionDefinition.cs | 42 ++--- .../BuiltinFunctions/BranchFunction.cs | 7 - .../BuiltinFunctions/BreakFunction.cs | 14 -- .../CreateWorkflowFunction.cs | 16 -- .../Workflow/BuiltinFunctions/FlipFunction.cs | 20 -- .../Workflow/BuiltinFunctions/GetFunction.cs | 29 +-- .../GetPluginInfoByNameFunction.cs | 16 -- .../BuiltinFunctions/InstallPluginFunction.cs | 16 -- .../BuiltinFunctions/JsonGetFieldFunction.cs | 16 -- .../ListPluginNamesFunction.cs | 15 -- .../BuiltinFunctions/ListWorkflowsFunction.cs | 15 -- .../Workflow/BuiltinFunctions/LoopFunction.cs | 7 - .../BuiltinFunctions/PauseFunction.cs | 16 -- .../BuiltinFunctions/PluginCallFunction.cs | 19 -- .../PluginCallWithTargetFunction.cs | 20 -- .../BuiltinFunctions/PrintFunction.cs | 16 -- .../BuiltinFunctions/ReadTextFileFunction.cs | 16 -- .../BuiltinFunctions/RunWorkflowFunction.cs | 16 -- .../Workflow/BuiltinFunctions/SetFunction.cs | 25 --- .../BuiltinFunctions/StartPluginFunction.cs | 16 -- .../BuiltinFunctions/StopPluginFunction.cs | 16 -- .../BuiltinFunctions/StopWorkflowFunction.cs | 16 -- .../BuiltinFunctions/ToLoopCondFunction.cs | 8 - .../BuiltinFunctions/TryGetDeviceFunction.cs | 12 +- .../BuiltinFunctions/WriteTextFileFunction.cs | 16 -- .../Workflow/Conversion/BS2CFGConverter.cs | 176 ++++++++---------- 26 files changed, 110 insertions(+), 491 deletions(-) diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/IBuiltinFunctionDefinition.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/IBuiltinFunctionDefinition.cs index e7d5c272..ec2095c9 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/IBuiltinFunctionDefinition.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/IBuiltinFunctionDefinition.cs @@ -38,24 +38,6 @@ public interface IBuiltinFunctionDefinition /// CFGStatementKind StatementKind { get; } - // ─── 语句字段提取(可选,默认无操作)────────────── - - /// - /// 从调用表达式中提取语句特定的字段(如 Set 的变量名、Get 的变量名和 PubVar)。 - /// BS2CFGConverter.FormatInvocation 在处理已注册函数时调用此方法获取 Kind 之外的特殊字段。 - /// 默认实现不提取任何特殊字段。 - /// - /// 原始 Roslyn 调用表达式 - /// 已展开的参数列表(可被修改,如 Set 移除第一个参数) - /// 语句左侧的赋值变量名(可能为 null) - /// 管线上下文(可用于 PubVar 计数器等状态) - (string? setVarName, string? getVarName, string? pubVarTarget) ExtractStatementFields( - InvocationExpressionSyntax invoke, - List expandedArgs, - string? assignedVar, - PipelineContext context) - => (null, null, null); - // ─── 节点布局 ─────────────────────────────────── /// 蓝图节点宽度 @@ -81,14 +63,30 @@ public interface IBuiltinFunctionDefinition // ─── 格式化(AST → CFGStatement)───────── /// - /// 将函数调用格式化为展开后的 CFGStatement 列表。 - /// 嵌套参数应在此时被展开为 PubVar 赋值。 + /// 将已展开参数的调用降低为 CFGStatement 列表(AST→CFG 阶段,统一服务顶层与嵌套)。 + /// 默认实现产出单条通用语句(Kind=StatementKind, Arguments=expandedArgs, PubVarTarget=assignedVar); + /// 需要 PubVar 生成等自定义逻辑的函数(如 Get/TryGetDevice)覆写此方法。 + /// BS2CFGConverter 对返回语句做横切后处理(StatementId/Fingerprint/PubVarNames 追踪)。 /// - List FormatInvocation( + List LowerToCFG( InvocationExpressionSyntax invoke, + IReadOnlyList expandedArgs, string blockName, PipelineContext context, - string? assignedVar); + string? assignedVar) + => new() + { + new CFGStatement + { + BlockName = blockName, + Kind = StatementKind, + FunctionName = FunctionName, + Arguments = expandedArgs.ToList(), + PubVarTarget = assignedVar, + OriginalExpression = invoke.ToString(), + SourceLine = 0, + } + }; // ─── 节点构建(CFGStatement → BlueprintNode)── diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/BranchFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/BranchFunction.cs index 9c7394c0..094373c7 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/BranchFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/BranchFunction.cs @@ -45,13 +45,6 @@ public class BranchFunction : IBuiltinFunctionDefinition return stmt; } - public List FormatInvocation( - InvocationExpressionSyntax invoke, string blockName, - PipelineContext context, string? assignedVar) - { - return []; - } - public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) => node; public void OnNodeCreated(BlueprintNode node, CFGStatement stmt, PipelineContext context) diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/BreakFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/BreakFunction.cs index f0b3a1e2..1a323548 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/BreakFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/BreakFunction.cs @@ -32,20 +32,6 @@ public class BreakFunction : IBuiltinFunctionDefinition public List EmitStatements(CFGStatement stmt, CSEmitContext ctx) => new() { ctx.Return() }; - public List FormatInvocation( - InvocationExpressionSyntax invoke, string blockName, - PipelineContext context, string? assignedVar) - { - return [new CFGStatement - { - BlockName = blockName, - Kind = CFGStatementKind.Break, - FunctionName = FunctionName, - OriginalExpression = invoke.ToString(), - SourceLine = 0, - }]; - } - public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) => node; public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/CreateWorkflowFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/CreateWorkflowFunction.cs index c8487f6d..44ece492 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/CreateWorkflowFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/CreateWorkflowFunction.cs @@ -30,22 +30,6 @@ public class CreateWorkflowFunction : IBuiltinFunctionDefinition public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; - public List FormatInvocation( - InvocationExpressionSyntax invoke, string blockName, - PipelineContext context, string? assignedVar) - { - var args = invoke.ArgumentList.Arguments.Select(a => a.Expression.ToString()).ToList(); - return [new CFGStatement - { - BlockName = blockName, - Kind = CFGStatementKind.Expression, - FunctionName = FunctionName, - Arguments = args, - OriginalExpression = invoke.ToString(), - SourceLine = 0, - }]; - } - public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) { if (node is BuiltinFunctionNode bfn && stmt.Arguments?.Count > 0) diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/FlipFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/FlipFunction.cs index 1a135a13..0d0f1690 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/FlipFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/FlipFunction.cs @@ -48,26 +48,6 @@ public class FlipFunction : IBuiltinFunctionDefinition return stmt; } - public List FormatInvocation( - InvocationExpressionSyntax invoke, string blockName, - PipelineContext context, string? assignedVar) - { - var args = invoke.ArgumentList.Arguments; - var trueBlock = args.Count >= 1 ? GetStringLiteral(args[0].Expression) : ""; - var falseBlock = args.Count >= 2 ? GetStringLiteral(args[1].Expression) : ""; - - return [new CFGStatement - { - BlockName = blockName, - Kind = CFGStatementKind.Branch, // Reuse Branch kind for pipeline routing - FunctionName = FunctionName, - TrueBlockName = trueBlock, - FalseBlockName = falseBlock, - OriginalExpression = invoke.ToString(), - SourceLine = 0, - }]; - } - public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) => node; public void OnNodeCreated(BlueprintNode node, CFGStatement stmt, PipelineContext context) diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/GetFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/GetFunction.cs index ee9b8b28..ec1acb96 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/GetFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/GetFunction.cs @@ -39,12 +39,10 @@ public List EmitStatements(CFGStatement stmt, CSEmitContext ctx return ctx.EmitValueAssignment(stmt.PubVarTarget, CFG2CSGenerator.BuildGetInvocation(varName)); } - public List FormatInvocation( - InvocationExpressionSyntax invoke, string blockName, - PipelineContext context, string? assignedVar) + public List LowerToCFG( + InvocationExpressionSyntax invoke, IReadOnlyList expandedArgs, + string blockName, PipelineContext context, string? assignedVar) { - var args = invoke.ArgumentList.Arguments.Select(a => a.Expression.ToString()).ToList(); - // Auto-generate PubVar if not already assigned string? pubVarTarget; if (string.IsNullOrEmpty(assignedVar) || !context.PubVarNames.Contains(assignedVar)) @@ -64,7 +62,7 @@ public List FormatInvocation( Kind = CFGStatementKind.Assignment, FunctionName = FunctionName, PubVarTarget = pubVarTarget, - Arguments = args, + Arguments = expandedArgs.ToList(), OriginalExpression = invoke.ToString(), SourceLine = 0, }]; @@ -83,25 +81,6 @@ public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) return node; } - public (string?, string?, string?) ExtractStatementFields( - InvocationExpressionSyntax invoke, List expandedArgs, - string? assignedVar, PipelineContext context) - { - string? pubVarTarget; - if (string.IsNullOrEmpty(assignedVar) || !context.PubVarNames.Contains(assignedVar)) - { - pubVarTarget = ExprUtils.GeneratePubVarName(context.NextPubVarCounter++); - if (!context.PubVarNames.Contains(pubVarTarget)) - context.PubVarNames.Add(pubVarTarget); - } - else - { - pubVarTarget = assignedVar; - } - - return (null, null, pubVarTarget); - } - public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) { if (!helper.IsOutputConsumed(node, "Value")) diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/GetPluginInfoByNameFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/GetPluginInfoByNameFunction.cs index 6dd6f900..e4411ef9 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/GetPluginInfoByNameFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/GetPluginInfoByNameFunction.cs @@ -31,22 +31,6 @@ public class GetPluginInfoByNameFunction : IBuiltinFunctionDefinition public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; - public List FormatInvocation( - InvocationExpressionSyntax invoke, string blockName, - PipelineContext context, string? assignedVar) - { - var args = invoke.ArgumentList.Arguments.Select(a => a.Expression.ToString()).ToList(); - return [new CFGStatement - { - BlockName = blockName, - Kind = CFGStatementKind.Expression, - FunctionName = FunctionName, - Arguments = args, - OriginalExpression = invoke.ToString(), - SourceLine = 0, - }]; - } - public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) { if (node is BuiltinFunctionNode bfn && stmt.Arguments?.Count > 0) diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/InstallPluginFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/InstallPluginFunction.cs index 922bfb57..d2c3d0b8 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/InstallPluginFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/InstallPluginFunction.cs @@ -30,22 +30,6 @@ public class InstallPluginFunction : IBuiltinFunctionDefinition public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; - public List FormatInvocation( - InvocationExpressionSyntax invoke, string blockName, - PipelineContext context, string? assignedVar) - { - var args = invoke.ArgumentList.Arguments.Select(a => a.Expression.ToString()).ToList(); - return [new CFGStatement - { - BlockName = blockName, - Kind = CFGStatementKind.Expression, - FunctionName = FunctionName, - Arguments = args, - OriginalExpression = invoke.ToString(), - SourceLine = 0, - }]; - } - public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) => node; public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/JsonGetFieldFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/JsonGetFieldFunction.cs index ab785847..57214d64 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/JsonGetFieldFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/JsonGetFieldFunction.cs @@ -31,22 +31,6 @@ public class JsonGetFieldFunction : IBuiltinFunctionDefinition public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; - public List FormatInvocation( - InvocationExpressionSyntax invoke, string blockName, - PipelineContext context, string? assignedVar) - { - var args = invoke.ArgumentList.Arguments.Select(a => a.Expression.ToString()).ToList(); - return [new CFGStatement - { - BlockName = blockName, - Kind = CFGStatementKind.Expression, - FunctionName = FunctionName, - Arguments = args, - OriginalExpression = invoke.ToString(), - SourceLine = 0, - }]; - } - public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) { if (node is BuiltinFunctionNode bfn && stmt.Arguments?.Count > 1) diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/ListPluginNamesFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/ListPluginNamesFunction.cs index 6993e964..2038c5f4 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/ListPluginNamesFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/ListPluginNamesFunction.cs @@ -30,21 +30,6 @@ public class ListPluginNamesFunction : IBuiltinFunctionDefinition public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; - public List FormatInvocation( - InvocationExpressionSyntax invoke, string blockName, - PipelineContext context, string? assignedVar) - { - return [new CFGStatement - { - BlockName = blockName, - Kind = CFGStatementKind.Expression, - FunctionName = FunctionName, - Arguments = [], - OriginalExpression = invoke.ToString(), - SourceLine = 0, - }]; - } - public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) => node; public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/ListWorkflowsFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/ListWorkflowsFunction.cs index 136ac494..4bb1bba0 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/ListWorkflowsFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/ListWorkflowsFunction.cs @@ -29,21 +29,6 @@ public class ListWorkflowsFunction : IBuiltinFunctionDefinition public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; - public List FormatInvocation( - InvocationExpressionSyntax invoke, string blockName, - PipelineContext context, string? assignedVar) - { - return [new CFGStatement - { - BlockName = blockName, - Kind = CFGStatementKind.Expression, - FunctionName = FunctionName, - Arguments = [], - OriginalExpression = invoke.ToString(), - SourceLine = 0, - }]; - } - public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) => node; public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/LoopFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/LoopFunction.cs index ac2ae2a3..fb9c1ecc 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/LoopFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/LoopFunction.cs @@ -46,13 +46,6 @@ public class LoopFunction : IBuiltinFunctionDefinition return stmt; } - public List FormatInvocation( - InvocationExpressionSyntax invoke, string blockName, - PipelineContext context, string? assignedVar) - { - return []; - } - public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) => node; public void OnNodeCreated(BlueprintNode node, CFGStatement stmt, PipelineContext context) diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PauseFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PauseFunction.cs index b40efedd..0cc9cceb 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PauseFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PauseFunction.cs @@ -36,22 +36,6 @@ public List EmitStatements(CFGStatement stmt, CSEmitContext ctx return new() { ctx.GInvokeStatement(FunctionName, ctx.ResolveArgument(stmt.Arguments[0])) }; } - public List FormatInvocation( - InvocationExpressionSyntax invoke, string blockName, - PipelineContext context, string? assignedVar) - { - var args = invoke.ArgumentList.Arguments.Select(a => a.Expression.ToString()).ToList(); - return [new CFGStatement - { - BlockName = blockName, - Kind = CFGStatementKind.Pause, - FunctionName = FunctionName, - Arguments = args, - OriginalExpression = invoke.ToString(), - SourceLine = 0, - }]; - } - public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) => node; public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PluginCallFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PluginCallFunction.cs index d8c1b7f6..6d664987 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PluginCallFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PluginCallFunction.cs @@ -36,25 +36,6 @@ public class PluginCallFunction : IBuiltinFunctionDefinition public List EmitStatements(CFGStatement stmt, CSEmitContext ctx) => ctx.EmitValueAssignment(stmt.PubVarTarget, CFG2CSGenerator.BuildPluginCallExpression(stmt, ctx.PubVarTypes)); - public List FormatInvocation( - InvocationExpressionSyntax invoke, string blockName, - PipelineContext context, string? assignedVar) - { - var args = invoke.ArgumentList.Arguments - .Select(a => a.Expression.ToString()) - .ToList(); - return [new CFGStatement - { - BlockName = blockName, - Kind = CFGStatementKind.Expression, - FunctionName = FunctionName, - PubVarTarget = null, - Arguments = args, - OriginalExpression = invoke.ToString(), - SourceLine = 0, - }]; - } - public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) => node; public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) => null; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PluginCallWithTargetFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PluginCallWithTargetFunction.cs index a36e1a55..c0b969c7 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PluginCallWithTargetFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PluginCallWithTargetFunction.cs @@ -39,26 +39,6 @@ public class PluginCallWithTargetFunction : IBuiltinFunctionDefinition public List EmitStatements(CFGStatement stmt, CSEmitContext ctx) => ctx.EmitValueAssignment(stmt.PubVarTarget, CFG2CSGenerator.BuildPluginCallWithTargetExpression(stmt, ctx.PubVarTypes)); - public List FormatInvocation( - InvocationExpressionSyntax invoke, string blockName, - PipelineContext context, string? assignedVar) - { - var args = invoke.ArgumentList.Arguments - .Select(a => a.Expression.ToString()) - .ToList(); - - return [new CFGStatement - { - BlockName = blockName, - Kind = CFGStatementKind.PluginCallWithTarget, - FunctionName = FunctionName, - PubVarTarget = assignedVar, // Set PubVarTarget so formatter creates PubVar assignment like other extractable functions - Arguments = args, - OriginalExpression = invoke.ToString(), - SourceLine = 0, - }]; - } - public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) { if (node is CallNode call) diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PrintFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PrintFunction.cs index e0a3b351..1fd63332 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PrintFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PrintFunction.cs @@ -36,22 +36,6 @@ public List EmitStatements(CFGStatement stmt, CSEmitContext ctx return new() { ctx.GInvokeStatement(FunctionName, ctx.ResolveArgument(stmt.Arguments[0])) }; } - public List FormatInvocation( - InvocationExpressionSyntax invoke, string blockName, - PipelineContext context, string? assignedVar) - { - var args = invoke.ArgumentList.Arguments.Select(a => a.Expression.ToString()).ToList(); - return [new CFGStatement - { - BlockName = blockName, - Kind = CFGStatementKind.Print, - FunctionName = FunctionName, - Arguments = args, - OriginalExpression = invoke.ToString(), - SourceLine = 0, - }]; - } - public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) => node; public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/ReadTextFileFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/ReadTextFileFunction.cs index fc033616..32595888 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/ReadTextFileFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/ReadTextFileFunction.cs @@ -29,22 +29,6 @@ public class ReadTextFileFunction : IBuiltinFunctionDefinition public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; - public List FormatInvocation( - InvocationExpressionSyntax invoke, string blockName, - PipelineContext context, string? assignedVar) - { - var args = invoke.ArgumentList.Arguments.Select(a => a.Expression.ToString()).ToList(); - return [new CFGStatement - { - BlockName = blockName, - Kind = CFGStatementKind.Expression, - FunctionName = FunctionName, - Arguments = args, - OriginalExpression = invoke.ToString(), - SourceLine = 0, - }]; - } - public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) => node; public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/RunWorkflowFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/RunWorkflowFunction.cs index 97188494..45820e97 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/RunWorkflowFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/RunWorkflowFunction.cs @@ -29,22 +29,6 @@ public class RunWorkflowFunction : IBuiltinFunctionDefinition public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; - public List FormatInvocation( - InvocationExpressionSyntax invoke, string blockName, - PipelineContext context, string? assignedVar) - { - var args = invoke.ArgumentList.Arguments.Select(a => a.Expression.ToString()).ToList(); - return [new CFGStatement - { - BlockName = blockName, - Kind = CFGStatementKind.Expression, - FunctionName = FunctionName, - Arguments = args, - OriginalExpression = invoke.ToString(), - SourceLine = 0, - }]; - } - public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) => node; public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/SetFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/SetFunction.cs index 177b9bb0..b6b294ed 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/SetFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/SetFunction.cs @@ -44,23 +44,6 @@ public List EmitStatements(CFGStatement stmt, CSEmitContext ctx return result; } - public List FormatInvocation( - InvocationExpressionSyntax invoke, string blockName, - PipelineContext context, string? assignedVar) - { - var currentArgExprs = invoke.ArgumentList.Arguments.Select(a => a.Expression.ToString()).ToList(); - - return [new CFGStatement - { - BlockName = blockName, - Kind = CFGStatementKind.Set, - FunctionName = FunctionName, - Arguments = currentArgExprs, - OriginalExpression = invoke.ToString(), - SourceLine = 0, - }]; - } - public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) { // Set default value on VarName pin from first argument @@ -74,14 +57,6 @@ public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) return node; } - public (string?, string?, string?) ExtractStatementFields( - InvocationExpressionSyntax invoke, List expandedArgs, - string? assignedVar, PipelineContext context) - { - // Don't strip first arg — it stays as the VarName pin argument - return (null, null, null); - } - public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) { var varPin = node.InputPins.FirstOrDefault(p => p.Name == "VarName"); diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/StartPluginFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/StartPluginFunction.cs index 6a3e26fb..adef6ea3 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/StartPluginFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/StartPluginFunction.cs @@ -30,22 +30,6 @@ public class StartPluginFunction : IBuiltinFunctionDefinition public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; - public List FormatInvocation( - InvocationExpressionSyntax invoke, string blockName, - PipelineContext context, string? assignedVar) - { - var args = invoke.ArgumentList.Arguments.Select(a => a.Expression.ToString()).ToList(); - return [new CFGStatement - { - BlockName = blockName, - Kind = CFGStatementKind.Expression, - FunctionName = FunctionName, - Arguments = args, - OriginalExpression = invoke.ToString(), - SourceLine = 0, - }]; - } - public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) => node; public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/StopPluginFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/StopPluginFunction.cs index e884bbb1..8c3cb271 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/StopPluginFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/StopPluginFunction.cs @@ -30,22 +30,6 @@ public class StopPluginFunction : IBuiltinFunctionDefinition public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; - public List FormatInvocation( - InvocationExpressionSyntax invoke, string blockName, - PipelineContext context, string? assignedVar) - { - var args = invoke.ArgumentList.Arguments.Select(a => a.Expression.ToString()).ToList(); - return [new CFGStatement - { - BlockName = blockName, - Kind = CFGStatementKind.Expression, - FunctionName = FunctionName, - Arguments = args, - OriginalExpression = invoke.ToString(), - SourceLine = 0, - }]; - } - public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) => node; public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/StopWorkflowFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/StopWorkflowFunction.cs index e4919037..fc1af6cc 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/StopWorkflowFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/StopWorkflowFunction.cs @@ -29,22 +29,6 @@ public class StopWorkflowFunction : IBuiltinFunctionDefinition public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; - public List FormatInvocation( - InvocationExpressionSyntax invoke, string blockName, - PipelineContext context, string? assignedVar) - { - var args = invoke.ArgumentList.Arguments.Select(a => a.Expression.ToString()).ToList(); - return [new CFGStatement - { - BlockName = blockName, - Kind = CFGStatementKind.Expression, - FunctionName = FunctionName, - Arguments = args, - OriginalExpression = invoke.ToString(), - SourceLine = 0, - }]; - } - public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) => node; public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/ToLoopCondFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/ToLoopCondFunction.cs index 49a1accd..749493e7 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/ToLoopCondFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/ToLoopCondFunction.cs @@ -48,14 +48,6 @@ public class ToLoopCondFunction : IBuiltinFunctionDefinition return stmt; } - public List FormatInvocation( - InvocationExpressionSyntax invoke, string blockName, - PipelineContext context, string? assignedVar) - { - // ToLoopCond is handled through FormatFlowControl, not FormatInvocation. - return []; - } - public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) => node; public void OnNodeCreated(BlueprintNode node, CFGStatement stmt, PipelineContext context) diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/TryGetDeviceFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/TryGetDeviceFunction.cs index 9e2217a6..4fd538c6 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/TryGetDeviceFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/TryGetDeviceFunction.cs @@ -44,14 +44,10 @@ public List EmitStatements(CFGStatement stmt, CSEmitContext ctx return new(); } - public List FormatInvocation( - InvocationExpressionSyntax invoke, string blockName, - PipelineContext context, string? assignedVar) + public List LowerToCFG( + InvocationExpressionSyntax invoke, IReadOnlyList expandedArgs, + string blockName, PipelineContext context, string? assignedVar) { - var args = invoke.ArgumentList.Arguments - .Select(a => a.Expression.ToString()) - .ToList(); - string? pubVarTarget; if (!string.IsNullOrEmpty(assignedVar)) { @@ -70,7 +66,7 @@ public List FormatInvocation( Kind = CFGStatementKind.TryGetDevice, FunctionName = FunctionName, PubVarTarget = pubVarTarget, - Arguments = args, + Arguments = expandedArgs.ToList(), OriginalExpression = invoke.ToString(), SourceLine = 0, }]; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/WriteTextFileFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/WriteTextFileFunction.cs index 6450045b..6ee0db8a 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/WriteTextFileFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/WriteTextFileFunction.cs @@ -29,22 +29,6 @@ public class WriteTextFileFunction : IBuiltinFunctionDefinition public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; - public List FormatInvocation( - InvocationExpressionSyntax invoke, string blockName, - PipelineContext context, string? assignedVar) - { - var args = invoke.ArgumentList.Arguments.Select(a => a.Expression.ToString()).ToList(); - return [new CFGStatement - { - BlockName = blockName, - Kind = CFGStatementKind.Expression, - FunctionName = FunctionName, - Arguments = args, - OriginalExpression = invoke.ToString(), - SourceLine = 0, - }]; - } - public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) => node; public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/BS2CFGConverter.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/BS2CFGConverter.cs index f2d49e6f..bbac769e 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/BS2CFGConverter.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/BS2CFGConverter.cs @@ -206,7 +206,7 @@ private List FormatExpressionStatement(ExpressionStatement exprStm return result; var fullFuncName = ExprUtils.GetFullMethodName(invoke); - result.AddRange(FormatInvocation(invoke, funcName, blockName, context, assignedVar, fullFuncName, + result.AddRange(LowerAndPostProcess(invoke, funcName, blockName, context, assignedVar, fullFuncName, statementId: exprStmt.StatementId)); return result; } @@ -218,82 +218,79 @@ private List FormatExpressionStatement(ExpressionStatement exprStm /// /// Formats a function invocation, expanding nested calls in arguments. /// - private List FormatInvocation( + private List LowerAndPostProcess( InvocationExpressionSyntax invoke, string funcName, string blockName, PipelineContext context, string? assignedVar, string? fullFuncName = null, string? statementId = null) { + // "_" is the dummy LHS produced by ExprUtils.ParseStatement for standalone + // expression statements (`_ = WriteTextFile(...)`); treat it as no assignment. + if (assignedVar == "_") assignedVar = null; + var result = new List(); // Expand nested calls in arguments first var (expansionStmts, currentArgExprs) = ExpandArguments(invoke, blockName, context); result.AddRange(expansionStmts); - // Determine statement kind and extract info - CFGStatementKind kind; - string? pubVarTarget = null; - string? setVarName = null; - string? getVarName = null; - - switch (funcName) + // Lower: registered builtins via their descriptor; helpers/unknown via fallback assembly. + List lowered; + if (_functionRegistry != null && _functionRegistry.Get(funcName) is { } funcDef) { - default: - // All registered functions (Set/Get/Print/Pause/etc.) go through registry - if (_functionRegistry != null && _functionRegistry.Get(funcName) is { } funcDef) - { - kind = funcDef.StatementKind; - var (sn, gn, pv) = funcDef.ExtractStatementFields(invoke, currentArgExprs, assignedVar, context); - setVarName = sn; - getVarName = gn; - pubVarTarget = pv; - // If ExtractStatementFields didn't set PubVarTarget but we have an assignment context - // and the function produces a value (non-void return), preserve the assignment - if (pubVarTarget == null && !string.IsNullOrEmpty(assignedVar) && assignedVar != "_") - { - pubVarTarget = assignedVar; - if (!context.PubVarNames.Contains(assignedVar)) - context.PubVarNames.Add(assignedVar); - } - } - else - { - // Helper or regular function call - if (!string.IsNullOrEmpty(assignedVar) && assignedVar != "_") - { - kind = CFGStatementKind.Assignment; - pubVarTarget = assignedVar; - // Ensure the assigned variable is tracked as a PubVar - if (!context.PubVarNames.Contains(assignedVar)) - context.PubVarNames.Add(assignedVar); - } - else - { - kind = CFGStatementKind.Expression; - } - } - break; + lowered = funcDef.LowerToCFG(invoke, currentArgExprs, blockName, context, assignedVar); } + else + { + // Helper or regular function call + CFGStatementKind kind; + string? pubVarTarget = null; + if (!string.IsNullOrEmpty(assignedVar) && assignedVar != "_") + { + kind = CFGStatementKind.Assignment; + pubVarTarget = assignedVar; + if (!context.PubVarNames.Contains(assignedVar)) + context.PubVarNames.Add(assignedVar); + } + else + { + kind = CFGStatementKind.Expression; + } + lowered = [new CFGStatement + { + BlockName = blockName, + Kind = kind, + FunctionName = funcName, + FullFunctionName = fullFuncName, + PubVarTarget = pubVarTarget, + Arguments = currentArgExprs, + OriginalExpression = invoke.ToString(), + SourceLine = 0, + }]; + } + result.AddRange(lowered); - var fingerprint = kind is CFGStatementKind.Assignment or CFGStatementKind.Expression - ? ExprUtils.ComputeFingerprint(funcName, currentArgExprs) - : null; - - result.Add(new CFGStatement + // Cross-cutting post-processing: descriptor owns core fields, BS2CFG owns bookkeeping. + foreach (var s in lowered) { - StatementId = !string.IsNullOrEmpty(statementId) ? statementId - : Guid.NewGuid().ToString(), - BlockName = blockName, - Kind = kind, - FunctionName = funcName, - FullFunctionName = fullFuncName, - PubVarTarget = pubVarTarget, - SetVarName = setVarName, - GetVarName = getVarName, - Arguments = currentArgExprs, - OriginalExpression = invoke.ToString(), - SourceLine = 0, - Fingerprint = fingerprint - }); + if (string.IsNullOrEmpty(s.StatementId)) + s.StatementId = !string.IsNullOrEmpty(statementId) ? statementId : Guid.NewGuid().ToString(); + + if (s.Fingerprint == null + && s.Kind is CFGStatementKind.Assignment or CFGStatementKind.Expression) + { + s.Fingerprint = ExprUtils.ComputeFingerprint(funcName, currentArgExprs); + } + + s.FullFunctionName ??= fullFuncName; + + // Preserve the deleted PubVar-fallback side-effect: when assignedVar is used as + // the statement's target, ensure it is tracked as a PubVar. + if (!string.IsNullOrEmpty(assignedVar) && assignedVar != "_" + && s.PubVarTarget == assignedVar && !context.PubVarNames.Contains(assignedVar)) + { + context.PubVarNames.Add(assignedVar); + } + } return result; } @@ -343,46 +340,37 @@ private List FormatInvocation( var funcName = ExprUtils.GetMethodName(invoke); var fullFuncName = ExprUtils.GetFullMethodName(invoke); - // Check registry for function classification - if (_functionRegistry != null && _functionRegistry.Get(funcName) is { } inlineDef) + // Non-extractable / flow-control functions stay inline (cannot be nested-call results). + if (_functionRegistry != null && _functionRegistry.Get(funcName) is { } inlineDef + && (inlineDef.IsNonExtractable || inlineDef.IsFlowControl)) { - // Non-extractable functions (Set/Print/Pause) stay inline - if (inlineDef.IsNonExtractable) - return (new(), invoke.ToString()); - - // Flow control functions → should not appear as arguments - if (inlineDef.IsFlowControl) - return (new(), invoke.ToString()); + return (new(), invoke.ToString()); } - // Check registry for functions that need extraction (e.g., Get) - // Registered functions use their FormatInvocation to create proper statements. + // Expand this call's arguments once (nested calls → PubVars), shared by both paths below. + var (expansionStmts, expandedArgs) = ExpandArguments(invoke, blockName, context); + + // Registered value-producing functions lower via their descriptor. if (_functionRegistry != null && _functionRegistry.Get(funcName) is { } regFuncDef) { - var formatted = regFuncDef.FormatInvocation(invoke, blockName, context, null); - var lastStmt = formatted.LastOrDefault(); + var lowered = regFuncDef.LowerToCFG(invoke, expandedArgs, blockName, context, null); + var lastStmt = lowered.LastOrDefault(); if (lastStmt?.PubVarTarget != null) - return (formatted, lastStmt.PubVarTarget); - } - - // This is a helper/regular function call that needs extraction - // First, recursively expand ITS arguments - var allStmts = new List(); - var currentArgs = new List(); - foreach (var arg in invoke.ArgumentList.Arguments) - { - var (expanded, finalExpr) = ExpandExpression(arg.Expression, blockName, context); - allStmts.AddRange(expanded); - currentArgs.Add(finalExpr); + { + var combined = new List(expansionStmts); + combined.AddRange(lowered); + return (combined, lastStmt.PubVarTarget); + } + // No target (e.g. PluginCallWithTarget nested) → fall through to helper path, + // which synthesizes a PubVar for the replacement expression. } - // Generate PubVar for this call + // Helper / regular function call: synthesize a PubVar assignment. var pubVarName = ExprUtils.GeneratePubVarName(context.NextPubVarCounter++); if (!context.PubVarNames.Contains(pubVarName)) context.PubVarNames.Add(pubVarName); - var fingerprint = ExprUtils.ComputeFingerprint(funcName, currentArgs); - + var allStmts = new List(expansionStmts); allStmts.Add(new CFGStatement { BlockName = blockName, @@ -390,9 +378,9 @@ private List FormatInvocation( PubVarTarget = pubVarName, FunctionName = funcName, FullFunctionName = fullFuncName, - Arguments = currentArgs, + Arguments = expandedArgs, OriginalExpression = $"{pubVarName} = {invoke}", - Fingerprint = fingerprint + Fingerprint = ExprUtils.ComputeFingerprint(funcName, expandedArgs) }); return (allStmts, pubVarName); @@ -445,7 +433,7 @@ private List FormatInvocation( if (string.IsNullOrEmpty(funcName)) return (result, null); var fullFuncName = ExprUtils.GetFullMethodName(invoke); - var formatted = FormatInvocation(invoke, funcName, blockName, context, null, fullFuncName); + var formatted = LowerAndPostProcess(invoke, funcName, blockName, context, null, fullFuncName); // The last statement should be the main call // If it already has a PubVarTarget, use it From e99381cc3b79aa20619073b05d023739dc0c621a Mon Sep 17 00:00:00 2001 From: StarInk Date: Mon, 15 Jun 2026 12:47:54 +0200 Subject: [PATCH 078/110] =?UTF-8?q?=F0=9F=A7=A9=20Refactor(Workflow):=20?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=20CFG2BP=20=E8=8A=82=E7=82=B9=E5=88=9B?= =?UTF-8?q?=E5=BB=BA=E4=B8=BA=E5=8D=95=E4=B8=80=E6=8F=8F=E8=BF=B0=E7=AC=A6?= =?UTF-8?q?=E9=A9=B1=E5=8A=A8=E8=B7=AF=E5=BE=84=E3=80=81=E5=88=A0=E9=99=A4?= =?UTF-8?q?=20PluginCallWithTarget=20=E6=97=A9=E8=B7=AF=E7=94=B1=E4=B8=8E?= =?UTF-8?q?=20Get=20=E5=A4=8D=E7=94=A8=E7=A1=AC=E7=BC=96=E7=A0=81-?= =?UTF-8?q?=E6=9C=80=E7=BB=88=E9=87=8D=E6=9E=84=E4=BC=98=E5=8C=96=E9=98=B6?= =?UTF-8?q?=E6=AE=B53?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../IBuiltinFunctionDefinition.cs | 29 +++ .../PluginCallWithTargetFunction.cs | 3 + .../Workflow/Conversion/CFG2BPConverter.cs | 232 +++++++----------- 3 files changed, 123 insertions(+), 141 deletions(-) diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/IBuiltinFunctionDefinition.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/IBuiltinFunctionDefinition.cs index ec2095c9..612694c0 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/IBuiltinFunctionDefinition.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/IBuiltinFunctionDefinition.cs @@ -38,6 +38,14 @@ public interface IBuiltinFunctionDefinition /// CFGStatementKind StatementKind { get; } + /// + /// 此内置函数在蓝图中物化为哪种节点类型。默认 ; + /// 需 形态(携带 PluginName/TargetDevice 等)的函数(如 + /// PluginCallWithTarget)覆写为 。 + /// 使 CFG2BPConverter 的节点创建走单一派发路径,无需按函数名特判。 + /// + BuiltinNodeKind NodeKind => BuiltinNodeKind.BuiltinFunction; + // ─── 节点布局 ─────────────────────────────────── /// 蓝图节点宽度 @@ -90,6 +98,15 @@ List LowerToCFG( // ─── 节点构建(CFGStatement → BlueprintNode)── + /// + /// 节点复用键(CFG2BPConverter 去重检测)。默认 null —— 每个语句建独立节点 + /// (对含副作用的调用更安全,避免重复副作用调用被折叠而破坏往返保真)。 + /// 需按指纹去重的纯值产生函数覆写为 等。 + /// 注册时若键为 null 则回落 PubVarTarget 仅为字典索引;DataEdgeBuilder 按 PubVarName + /// 字段连接数据边,不受键影响,故值产生函数仍可被正确连线。 + /// + string? GetReuseKey(CFGStatement stmt) => null; + /// /// 对新创建的 BuiltinFunctionNode 进行额外配置(如设置 Properties 字典)。 /// 返回配置后的节点。 @@ -132,3 +149,15 @@ List EmitStatements(CFGStatement stmt, CSEmitContext ctx) /// void OnNodeCreated(BlueprintNode node, CFGStatement stmt, PipelineContext context) { } } + +/// +/// 内置函数在蓝图中物化的节点类型。见 。 +/// +public enum BuiltinNodeKind +{ + /// 常规内置函数节点(由 NodeRegistry.CreateBuiltinFunctionNode 按描述符建引脚)。 + BuiltinFunction, + + /// 携带 PluginName/TargetDevice 的调用节点(bare CallNode + AddParamPins)。 + Call, +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PluginCallWithTargetFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PluginCallWithTargetFunction.cs index c0b969c7..0d3fa9f1 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PluginCallWithTargetFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PluginCallWithTargetFunction.cs @@ -19,6 +19,9 @@ public class PluginCallWithTargetFunction : IBuiltinFunctionDefinition public bool IsFlowControl => false; public bool IsNonExtractable => false; public CFGStatementKind StatementKind => CFGStatementKind.PluginCallWithTarget; + public BuiltinNodeKind NodeKind => BuiltinNodeKind.Call; + // Preserve prior early-route behavior: identical cross-device calls dedup to one node. + public string? GetReuseKey(CFGStatement stmt) => stmt.Fingerprint; public double NodeWidth => 140; public double NodeHeight => 80; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/CFG2BPConverter.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/CFG2BPConverter.cs index 981d14ce..2a4285c9 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/CFG2BPConverter.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/CFG2BPConverter.cs @@ -3,7 +3,6 @@ using Serilog; using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Pins; -using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Functions; using KitX.Core.Workflow.CFG; using KitX.Core.Workflow.Blueprint; @@ -113,47 +112,30 @@ private void ProcessBlock(CFGBlock block, PipelineContext context) private BlueprintNode? ProcessStatement(CFGStatement stmt, string blockName, PipelineContext context, ref BlueprintNode? prevNode, ref string? prevStmtId) { - // PluginCallWithTarget must become an explicit CallNode carrying TargetDevice. - // Route it before the registry dispatch — otherwise the registry creates a - // BuiltinFunctionNode that ConfigureNode (which expects a CallNode) cannot convert, - // leaving TargetDevice unset and producing no CallNode for the test to find. - if (stmt.FunctionName == "PluginCallWithTarget") - return ProcessCallOrAssignment(stmt, context, ref prevNode, ref prevStmtId); - - // Registry path: handle all registered builtin functions - if (_functionRegistry != null && !string.IsNullOrEmpty(stmt.FunctionName)) - { - var funcDef = _functionRegistry.Get(stmt.FunctionName); - if (funcDef != null) - { - if (funcDef.IsBlockTerminator) - { - var node = _registry.CreateBuiltinFunctionNode(stmt.FunctionName); - node = funcDef.ConfigureNode(node, stmt); - ChainNewNode(node, stmt, context, ref prevNode, ref prevStmtId); - funcDef.OnNodeCreated(node, stmt, context); - return node; - } + var funcDef = !string.IsNullOrEmpty(stmt.FunctionName) + ? _functionRegistry?.Get(stmt.FunctionName) + : null; - // Non-terminator registered function - var nonTermNode = _registry.CreateBuiltinFunctionNode(stmt.FunctionName); - nonTermNode = funcDef.ConfigureNode(nonTermNode, stmt); - var result = ChainNewNode(nonTermNode, stmt, context, ref prevNode, ref prevStmtId); - RegisterPubVarAssignment(stmt, nonTermNode, context); - return result; - } + // Control-flow terminators (Branch/Loop/Flip/ToLoopCond): node + deferred cross-block edges. + if (funcDef is { IsBlockTerminator: true }) + { + var node = CreateAndConfigureNode(stmt, funcDef); + ChainNewNode(node, stmt, context, ref prevNode, ref prevStmtId); + funcDef.OnNodeCreated(node, stmt, context); + return node; } - // Non-registry statement handling - switch (stmt.Kind) + // Calls / assignments: any registered non-terminator builtin (Get/Set/Print/..., + // including PluginCallWithTarget whose Kind is its own enum value) OR an unregistered + // helper/plugin call (Kind = Assignment/Expression). Routing by registration rather + // than Kind, because builtins like Print/Set carry their own Kind values. + if (funcDef != null + || stmt.Kind is CFGStatementKind.Assignment or CFGStatementKind.Expression) { - case CFGStatementKind.Assignment: - case CFGStatementKind.Expression: - return ProcessCallOrAssignment(stmt, context, ref prevNode, ref prevStmtId); - - default: - return null; + return ProcessCallOrAssignment(stmt, funcDef, context, ref prevNode, ref prevStmtId); } + + return null; } /// @@ -172,115 +154,92 @@ private bool IsRegistryFlowControlTerminator(CFGStatement stmt) // ────────────────────────────────────────────── private BlueprintNode? ProcessCallOrAssignment(CFGStatement stmt, - PipelineContext context, ref BlueprintNode? prevNode, ref string? prevStmtId) + IBuiltinFunctionDefinition? funcDef, PipelineContext context, + ref BlueprintNode? prevNode, ref string? prevStmtId) { // --- PubVar reuse check (§6.5) --- - // For Get statements: reuse by PubVarTarget (each Get is unique based on its PubVar) - // For non-Get statements: reuse by fingerprint - PubVarAssignment? existing = null; - - if (stmt.FunctionName == Get && stmt.Arguments?.Count > 0) - { - if (!string.IsNullOrEmpty(stmt.PubVarTarget)) - { - existing = context.PubVarAssignments.Values - .FirstOrDefault(p => p.PubVarName == stmt.PubVarTarget - && p.SourceNode is BuiltinFunctionNode bfn && bfn.FunctionName == "Get" - && bfn.InputPins.FirstOrDefault(pin => pin.Name == "VarName")?.DefaultValue == stmt.Arguments[0].Trim('"')); - } - } - else if (!string.IsNullOrEmpty(stmt.Fingerprint) - && context.PubVarAssignments.TryGetValue(stmt.Fingerprint, out var fpExisting)) - { - existing = fpExisting; - } + // Reuse key is descriptor-declared: null for most builtins (each stmt → own node, + // safe for side-effecting calls); Fingerprint for opt-ins (e.g. PluginCallWithTarget); + // Fingerprint fallback for unregistered helpers/plugin calls. + var reuseKey = funcDef == null ? stmt.Fingerprint : funcDef.GetReuseKey(stmt); - if (existing != null) + if (!string.IsNullOrEmpty(reuseKey) + && context.PubVarAssignments.TryGetValue(reuseKey, out var existing)) { context.NodeByStatementId[stmt.StatementId] = existing.SourceNode; - - // Chain to the shared main node AddExecEdge(prevStmtId, stmt.StatementId, context); prevNode = existing.SourceNode; prevStmtId = stmt.StatementId; - - Log.Debug("[CFG2BPConverter] Reused node: {Key}", stmt.Fingerprint ?? stmt.PubVarTarget); + Log.Debug("[CFG2BPConverter] Reused node: {Key}", reuseKey); return existing.SourceNode; } - // --- Create new node --- - BlueprintNode mainNode; - if (stmt.FunctionName == "PluginCallWithTarget") - { - // PluginCallWithTarget must be an explicit CallNode carrying TargetDevice. - // Handle BEFORE the builtin registry dispatch — otherwise the registry shadows - // it (PluginCallWithTarget is a registered builtin) and TargetDevice is never set. - var callNode = (CallNode)_registry.Create(BlueprintNodeType.Call); - var args = stmt.Arguments; - // G.PluginCallWithTarget("plugin", "method", "device", ...) - // Arguments[0]=pluginName, [1]=methodName, [2]=targetDevice - callNode.PluginName = args?.Count > 0 ? StripQuotes(args[0]) : ""; - callNode.FunctionName = args?.Count > 1 ? StripQuotes(args[1]) : ""; - callNode.TargetDevice = args?.Count > 2 ? StripQuotes(args[2]) : null; - // Preserve extra arguments (beyond plugin/method/device) for BS round-trip, - // mirroring PluginCallWithTargetFunction.ConfigureNode. - if (args != null && args.Count > 3) - callNode.ExtraArguments = args.Skip(3).ToList(); - mainNode = callNode; - - // Add parameter pins based on argument count - AddParamPins(mainNode, stmt.FunctionName!, stmt.Arguments?.Count ?? 0); - } - else if (_functionRegistry != null && _functionRegistry.Get(stmt.FunctionName!) is { } funcDef) - { - mainNode = _registry.CreateBuiltinFunctionNode(stmt.FunctionName!); - mainNode = funcDef.ConfigureNode(mainNode, stmt); - } - else - { - var isHelper = _helperNames.Contains(stmt.FunctionName ?? string.Empty); - if (isHelper) - { - var helperNode = (CallHelperNode)_registry.Create(BlueprintNodeType.CallHelper); - helperNode.HelperFunctionName = stmt.FunctionName!; - mainNode = helperNode; - } - else - { - var callNode = (CallNode)_registry.Create(BlueprintNodeType.Call); - - // Parse plugin name from full dotted method name (e.g. "TestPlugin.WPF.Core.HelloKitX") - if (!string.IsNullOrEmpty(stmt.FullFunctionName) && stmt.FullFunctionName.Contains('.')) - { - var lastDot = stmt.FullFunctionName.LastIndexOf('.'); - callNode.PluginName = stmt.FullFunctionName.Substring(0, lastDot); - callNode.FunctionName = stmt.FullFunctionName.Substring(lastDot + 1); - } - else - { - callNode.FunctionName = stmt.FunctionName!; - } - - mainNode = callNode; - } - - // Add parameter pins based on helper definition or argument count - AddParamPins(mainNode, stmt.FunctionName!, stmt.Arguments?.Count ?? 0); - } + // --- Create + configure (single descriptor-driven path) --- + var mainNode = CreateAndConfigureNode(stmt, funcDef); context.AllNodes.Add(mainNode); context.NodeByStatementId[stmt.StatementId] = mainNode; - AddExecEdge(prevStmtId, stmt.StatementId, context); prevNode = mainNode; prevStmtId = stmt.StatementId; - // Register PubVar assignment for reuse - RegisterPubVarAssignment(stmt, mainNode, context); - + RegisterPubVarAssignment(stmt, mainNode, funcDef, context); return mainNode; } + /// + /// Single descriptor-driven node creation. Resolves the right node type from + /// (BuiltinFunction vs Call), applies + /// , and adds param pins for bare + /// Call/CallHelper nodes. Unregistered statements fall back to helper/plugin-call nodes. + /// + private BlueprintNode CreateAndConfigureNode(CFGStatement stmt, IBuiltinFunctionDefinition? funcDef) + { + BlueprintNode node; + if (funcDef != null) + { + node = funcDef.NodeKind == BuiltinNodeKind.Call + ? _registry.Create(BlueprintNodeType.Call) + : _registry.CreateBuiltinFunctionNode(stmt.FunctionName!); + node = funcDef.ConfigureNode(node, stmt); + // Bare CallNode has no descriptor pins → add param pins like helper/plugin calls. + if (funcDef.NodeKind == BuiltinNodeKind.Call) + AddParamPins(node, stmt.FunctionName!, stmt.Arguments?.Count ?? 0); + } + else + { + node = _helperNames.Contains(stmt.FunctionName ?? string.Empty) + ? CreateHelperNode(stmt) + : CreatePluginCallNode(stmt); + AddParamPins(node, stmt.FunctionName!, stmt.Arguments?.Count ?? 0); + } + return node; + } + + private BlueprintNode CreateHelperNode(CFGStatement stmt) + { + var helperNode = (CallHelperNode)_registry.Create(BlueprintNodeType.CallHelper); + helperNode.HelperFunctionName = stmt.FunctionName!; + return helperNode; + } + + private BlueprintNode CreatePluginCallNode(CFGStatement stmt) + { + var callNode = (CallNode)_registry.Create(BlueprintNodeType.Call); + // Parse plugin name from full dotted method name (e.g. "TestPlugin.WPF.Core.HelloKitX") + if (!string.IsNullOrEmpty(stmt.FullFunctionName) && stmt.FullFunctionName.Contains('.')) + { + var lastDot = stmt.FullFunctionName.LastIndexOf('.'); + callNode.PluginName = stmt.FullFunctionName.Substring(0, lastDot); + callNode.FunctionName = stmt.FullFunctionName.Substring(lastDot + 1); + } + else + { + callNode.FunctionName = stmt.FunctionName!; + } + return callNode; + } + // ────────────────────────────────────────────── // Cross-block edge resolution // ────────────────────────────────────────────── @@ -367,18 +326,19 @@ private BlueprintNode ChainNewNode(BlueprintNode node, CFGStatement stmt, return node; } - private void RegisterPubVarAssignment(CFGStatement stmt, BlueprintNode node, PipelineContext context) + private void RegisterPubVarAssignment(CFGStatement stmt, BlueprintNode node, + IBuiltinFunctionDefinition? funcDef, PipelineContext context) { if (string.IsNullOrEmpty(stmt.PubVarTarget)) return; var outputPin = node.OutputPins.FirstOrDefault(p => p.Type != PinType.Execution); if (outputPin == null) return; - // Get: key by PubVarTarget (each Get is unique based on what variable it reads) - // Others: key by fingerprint for reuse detection - var key = stmt.FunctionName == "Get" - ? stmt.PubVarTarget - : (stmt.Fingerprint ?? stmt.PubVarTarget); + // The key only matters for reuse lookup (ProcessCallOrAssignment). DataEdgeBuilder + // connects data edges by the PubVarName field below, so a null reuse key safely falls + // back to PubVarTarget as a non-null dict index without affecting data edges. + var reuseKey = funcDef == null ? stmt.Fingerprint : funcDef.GetReuseKey(stmt); + var key = reuseKey ?? stmt.PubVarTarget; context.PubVarAssignments[key] = new PubVarAssignment { PubVarName = stmt.PubVarTarget, @@ -438,14 +398,4 @@ private void AddParamPins(BlueprintNode node, string funcName, int argCount) } } } - - /// Strips surrounding double-quote characters from a string literal. - private static string StripQuotes(string s) - { - if (s == null) return ""; - s = s.Trim(); - if (s.Length >= 2 && s.StartsWith('"') && s.EndsWith('"')) - return s[1..^1]; - return s; - } } From 636b5e9091c9301d49d865bbabba23ab9bdae150 Mon Sep 17 00:00:00 2001 From: StarInk Date: Mon, 15 Jun 2026 14:31:09 +0200 Subject: [PATCH 079/110] =?UTF-8?q?=F0=9F=A7=A9=20Refactor(Workflow):=20?= =?UTF-8?q?=E5=8F=8D=E5=90=91=20BP2CFG=20=E6=B4=BE=E5=8F=91=E5=BD=92?= =?UTF-8?q?=E4=B8=80=E4=B8=BA=E6=8F=8F=E8=BF=B0=E7=AC=A6=E9=A9=B1=E5=8A=A8?= =?UTF-8?q?=E3=80=81=E5=88=A0=E9=99=A4=E6=AD=BB=E4=BB=A3=E7=A0=81=E4=B8=8E?= =?UTF-8?q?=20Get=20auto-PubVar=20=E7=A1=AC=E7=BC=96=E7=A0=81=E3=80=81?= =?UTF-8?q?=E8=A1=A5=E4=B8=8D=E5=8F=98=E9=87=8F=20ADR=E3=80=81=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E7=82=B9=E8=AF=AD=E6=B3=95=E7=BC=96=E8=AF=91=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E7=9A=84=E5=9B=9E=E5=BD=92=E9=97=AE=E9=A2=98-?= =?UTF-8?q?=E6=9C=80=E7=BB=88=E9=87=8D=E6=9E=84=E4=BC=98=E5=8C=96=E9=98=B6?= =?UTF-8?q?=E6=AE=B54?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../KitX.Core.BluePrint.Test/Program.cs | 55 ++++++++++++++++++- .../Workflow/BlockScripting/CSCompiler.cs | 4 +- .../IBuiltinFunctionDefinition.cs | 7 +++ .../Workflow/BuiltinFunctions/GetFunction.cs | 1 + .../KitX.Core/Workflow/CFG/CFGStatement.cs | 7 --- .../Workflow/Conversion/BP2CFGConverter.cs | 32 +++-------- .../BlockScriptToBlueprintConverter.cs | 2 +- .../BuiltinFunctionExportStrategyAdapter.cs | 3 + .../Workflow/Conversion/CFG2CSGenerator.cs | 8 +++ .../Conversion/CFGConditionDuplicator.cs | 2 - 10 files changed, 83 insertions(+), 38 deletions(-) diff --git a/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs b/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs index bb302d53..19766eb4 100644 --- a/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs +++ b/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs @@ -213,6 +213,14 @@ public static void Main(string[] args) Console.WriteLine("└──────────────────────────────────────────┘\n"); RunUninitializedVarTest(parser, sp); } + + if (ShouldRunTest("S")) + { + Console.WriteLine("\n┌──────────────────────────────────────────┐"); + Console.WriteLine("│ Test S: Qualified Plugin-Method Call CS │"); + Console.WriteLine("└──────────────────────────────────────────┘\n"); + RunQualifiedPluginCallTest(parser); + } } private static void ParseArgs(string[] args) @@ -256,7 +264,7 @@ private static void RunTest(BlockScriptToBlueprintConverter converter, var dup = stmt.IsLoopConditionDuplication ? " [LOOP_COND_DUP]" : ""; Console.WriteLine($" [{stmt.Kind}] {stmt.OriginalExpression}"); Console.WriteLine($" PubVarTarget={stmt.PubVarTarget} Func={stmt.FunctionName} Args=[{args}]"); - Console.WriteLine($" SetVar={stmt.SetVarName} GetVar={stmt.GetVarName} Fingerprint={stmt.Fingerprint}{dup}"); + Console.WriteLine($" Fingerprint={stmt.Fingerprint}{dup}"); } Console.WriteLine(); } @@ -1774,4 +1782,49 @@ private static void RunUninitializedVarTest( Console.WriteLine($"\n[Test R] {(allPassed ? "PASS - All phases passed!" : "FAIL - See above for details")}"); } + + // ────────────────────────────────────────────── + // Test S: Qualified Plugin-Method Call CS Generation + // Guards the Phase 1 regression where dotted plugin-method calls + // (e.g. TestPlugin.WPF.Core.GetInput()) lost their CS emission path + // and fell through to a bare-call fallback → CS0103. + // ────────────────────────────────────────────── + private static void RunQualifiedPluginCallTest(IBlockScriptParser parser) + { + Console.WriteLine("[Test S] Qualified plugin-method call → CS compilation (Phase 1 regression guard)"); + + var sourceCode = @" +#PubVarBlock +dynamic v; + +#MainBlock +v = TestPlugin.WPF.Core.GetInput(); +TestPlugin.WPF.Core.HelloAnything(v); +"; + + try + { + var parseResult = parser.Parse(sourceCode); + if (!parseResult.IsSuccess || parseResult.Script == null) + { + Console.WriteLine($"[Test S] FAIL: parse error: {parseResult.ErrorMessage}"); + Console.WriteLine("\n[Test S] FAIL - See above for details"); + return; + } + parseResult.Script.HelperFunctions = new List(); + + var compiler = new CSCompiler(); + var compiled = compiler.CompileScript(parseResult.Script); + + // Before the fix, the dotted calls emitted bare GetInput()/HelloAnything() → CS0103 → null. + // After the fix, they emit G.PluginCall("TestPlugin.WPF.Core", "GetInput", ...) and compile. + bool pass = compiled != null; + Console.WriteLine($" Compiled assembly: {(compiled != null ? "non-null" : "null (CS0103 regression)")}"); + Console.WriteLine($"[Test S] {(pass ? "PASS" : "FAIL - dotted plugin call did not compile")}"); + } + catch (Exception ex) + { + Console.WriteLine($"[Test S] FAILED: {ex.Message}\n {ex.StackTrace}"); + } + } } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/CSCompiler.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/CSCompiler.cs index 6852576d..4ba13a9f 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/CSCompiler.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/CSCompiler.cs @@ -245,9 +245,9 @@ public void ClearCache() Log.Debug("[CSCompiler] Block '{Name}' → NextBlock={Next}, Statements={Count}", block.Name, block.NextBlockName, block.Statements.Count); foreach (var stmt in block.Statements) - Log.Debug("[CSCompiler] Kind={Kind} PubVar={PubVar} Fn={Fn} Args=[{Args}] CondPubVar={Cond} SetVar={Set} GetVar={Get}", + Log.Debug("[CSCompiler] Kind={Kind} PubVar={PubVar} Fn={Fn} Args=[{Args}] CondPubVar={Cond}", stmt.Kind, stmt.PubVarTarget, stmt.FunctionName, - string.Join(", ", stmt.Arguments), stmt.ConditionPubVar, stmt.SetVarName, stmt.GetVarName); + string.Join(", ", stmt.Arguments), stmt.ConditionPubVar); } var pubVarTypes = CFG2CSGenerator.InferPubVarTypes(formattedScript, script.HelperFunctions, context); diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/IBuiltinFunctionDefinition.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/IBuiltinFunctionDefinition.cs index 612694c0..e9d47b36 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/IBuiltinFunctionDefinition.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/IBuiltinFunctionDefinition.cs @@ -46,6 +46,13 @@ public interface IBuiltinFunctionDefinition /// BuiltinNodeKind NodeKind => BuiltinNodeKind.BuiltinFunction; + /// + /// 反向(BP→CFG)时,若该节点的非 Exec 输出数据连接未带 PubVar(编辑器手建蓝图场景), + /// 是否为其自动合成一个 PubVar。默认 false;Get 覆写为 true(其值读取需显式命名承载)。 + /// 使 BP2CFGConverter 无需按函数名特判 Get。 + /// + bool AutoSynthesizePubVar => false; + // ─── 节点布局 ─────────────────────────────────── /// 蓝图节点宽度 diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/GetFunction.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/GetFunction.cs index ec1acb96..3d585e06 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/GetFunction.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/GetFunction.cs @@ -18,6 +18,7 @@ public class GetFunction : IBuiltinFunctionDefinition public bool IsFlowControl => false; public bool IsNonExtractable => false; public CFGStatementKind StatementKind => CFGStatementKind.Assignment; + public bool AutoSynthesizePubVar => true; public double NodeWidth => 140; public double NodeHeight => 60; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/CFG/CFGStatement.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/CFG/CFGStatement.cs index 60e9a567..249e1c79 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/CFG/CFGStatement.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/CFG/CFGStatement.cs @@ -136,13 +136,6 @@ public class CFGStatement /// public string? ToLoopCondReturnTo { get; set; } - // --- For Set / Get --- - /// The variable name being set (for Set statements). - public string? SetVarName { get; set; } - - /// The variable name being read (for Get statements). - public string? GetVarName { get; set; } - // --- Metadata --- /// /// True if this statement was inserted as a Loop condition duplication diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/BP2CFGConverter.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/BP2CFGConverter.cs index 191a6d3a..93f0ef9d 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/BP2CFGConverter.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/BP2CFGConverter.cs @@ -148,7 +148,7 @@ public ControlFlowGraph Build(Contract.Workflow.Blueprint blueprint) // Step 1: Connection Analysis // ════════════════════════════════════════════════════════════════════ - private static void AnalyzeConnections( + private void AnalyzeConnections( Contract.Workflow.Blueprint blueprint, Dictionary nodeById, List execConns, @@ -208,9 +208,14 @@ private static void AnalyzeConnections( if (sourceNode == null) continue; if (sourceNode.NodeType == BlueprintNodeType.Const) continue; + // Value sources needing an auto PubVar: plugin/helper calls, or a builtin whose + // descriptor declares AutoSynthesizePubVar (Get). Descriptor-driven — no name hardcoding. if (sourceNode.NodeType is BlueprintNodeType.Call or BlueprintNodeType.CallHelper - || (sourceNode is BuiltinFunctionNode bfn && bfn.FunctionName == "Get")) + || (sourceNode is BuiltinFunctionNode bfn + && _builtinFunctionStrategies.TryGetValue(bfn.FunctionName, out var strat) + && strat is BuiltinFunctionExportStrategyAdapter pubVarAdapter + && pubVarAdapter.AutoSynthesizePubVar)) { var sourcePin = sourceNode.GetPinById(conn.SourcePinId); if (sourcePin == null) continue; @@ -946,23 +951,6 @@ private CFGStatement ConvertBlockStatementToCfgStatement(BlockStatement blockStm cfgStmt.Arguments.Add(_exportHelper.GetInputValue(node, pin.Name)); } - // SetVarName / GetVarName from node input pins for Set/Get - if (node is BuiltinFunctionNode bfnProps) - { - if (bfnProps.FunctionName == "Set") - { - var varPin = node.InputPins.FirstOrDefault(p => p.Name == "VarName"); - if (varPin != null && !string.IsNullOrEmpty(varPin.DefaultValue)) - cfgStmt.SetVarName = varPin.DefaultValue; - } - else if (bfnProps.FunctionName == "Get") - { - var varPin = node.InputPins.FirstOrDefault(p => p.Name == "VarName"); - if (varPin != null && !string.IsNullOrEmpty(varPin.DefaultValue)) - cfgStmt.GetVarName = varPin.DefaultValue; - } - } - // PubVarTarget from consumed output lookup if (_currentCtx != null) { @@ -1130,10 +1118,4 @@ private bool IsControlFlowNode(BlueprintNode node) => node is BuiltinFunctionNode bfn && _builtinFunctionStrategies.TryGetValue(bfn.FunctionName, out var strat) && strat.IsControlFlow; - - private bool IsBranchNode(BlueprintNode node) => - node is BuiltinFunctionNode bfn && bfn.FunctionName == "Branch"; - - private bool IsLoopNode(BlueprintNode node) => - node is BuiltinFunctionNode bfn && bfn.FunctionName == "Loop"; } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/BlockScriptToBlueprintConverter.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/BlockScriptToBlueprintConverter.cs index cabce376..5961d865 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/BlockScriptToBlueprintConverter.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/BlockScriptToBlueprintConverter.cs @@ -163,7 +163,7 @@ public static string DumpFormattedScript(PipelineContext context) var args = stmt.Arguments != null ? string.Join(", ", stmt.Arguments) : ""; sb.AppendLine($" [{stmt.Kind}] {stmt.OriginalExpression}" + $" | PubVarTarget={stmt.PubVarTarget} Func={stmt.FunctionName}" + - $" Args=[{args}] SetVar={stmt.SetVarName} GetVar={stmt.GetVarName}" + + $" Args=[{args}]" + $" CondPubVar={stmt.ConditionPubVar} True={stmt.TrueBlockName} False={stmt.FalseBlockName}" + $" ToLoopCondReturnTo={stmt.ToLoopCondReturnTo}{dup}{fp}"); } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/BuiltinFunctionExportStrategyAdapter.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/BuiltinFunctionExportStrategyAdapter.cs index 88916dc7..efdc5f42 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/BuiltinFunctionExportStrategyAdapter.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/BuiltinFunctionExportStrategyAdapter.cs @@ -27,6 +27,9 @@ public BuiltinFunctionExportStrategyAdapter(IBuiltinFunctionDefinition definitio /// 代理底层定义的 StatementKind,用于 BP→CFG 时直接填充 CFGStatement 字段。 public CFGStatementKind StatementKind => _definition.StatementKind; + /// 代理底层定义:BP→CFG 时是否为该节点的空 PubVar 数据连接自动合成 PubVar。 + public bool AutoSynthesizePubVar => _definition.AutoSynthesizePubVar; + /// public bool IsControlFlow => _definition.IsFlowControl; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/CFG2CSGenerator.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/CFG2CSGenerator.cs index 2a69a797..49dfd3a2 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/CFG2CSGenerator.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/CFG2CSGenerator.cs @@ -573,6 +573,14 @@ internal static SwitchSectionSyntax GenerateFormattedBlockCase( sourceType = helperReturnTypes.TryGetValue(stmt.FunctionName ?? "", out var rt) ? rt : "object"; } + else if (stmt.FullFunctionName != null && stmt.FullFunctionName.Contains('.')) + { + // Dotted plugin-method call (e.g. TestPlugin.WPF.Core.GetInput()) — has no + // descriptor (it is a syntactic dotted name), so emit via + // G.PluginCall(pluginName, methodName, args). Structural predicate + // (qualified vs unqualified), not a function-name dispatch. + rawExpr = BuildPluginCallExpression(stmt, pubVarTypes); + } else { rawExpr = ParseExpression( diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/CFGConditionDuplicator.cs b/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/CFGConditionDuplicator.cs index 2fd778b3..6e96a4b7 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/CFGConditionDuplicator.cs +++ b/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/CFGConditionDuplicator.cs @@ -83,8 +83,6 @@ private static void InsertConditionDuplicates(CFGBlock bodyBlock, List Date: Mon, 15 Jun 2026 21:33:03 +0200 Subject: [PATCH 080/110] =?UTF-8?q?=F0=9F=A7=A9=20Refactor(Workflow):=20?= =?UTF-8?q?=E6=8F=90=E5=8F=96=20KitX.Workflow=20=E7=8B=AC=E7=AB=8B?= =?UTF-8?q?=E5=BA=93=E3=80=81=E7=98=A6=E8=BA=AB=20Core=20=E8=87=B3?= =?UTF-8?q?=E9=9D=9E=20workflow=E3=80=81bump=20Standard+Dashboard=20?= =?UTF-8?q?=E5=AD=90=E6=A8=A1=E5=9D=97=E6=8C=87=E9=92=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../KitX.Core.BluePrint.Test/Program.cs | 10 +- .../DI/CoreServiceCollectionExtensions.cs | 123 +--------------- .../KitX.Core/Device/DeviceHttpClient.cs | 23 +-- .../KitX Core/KitX.Core/KitX.Core.csproj | 1 + KitX Clients/KitX Dashboard | 2 +- .../KitX.Workflow}/BlockScriptServiceImpl.cs | 13 +- .../BlockScripting/BlockScopeManager.cs | 4 +- .../BlockScriptExecutionGlobals.cs | 4 +- .../BlockScripting/BlockScriptExecutor.cs | 8 +- .../BlockScripting/BlockScriptParser.cs | 4 +- .../BlockScripting/BlockScriptWellKnown.cs | 2 +- .../BlockScripting/BlockStatementExtractor.cs | 8 +- .../BlockStructureRecognizer.cs | 6 +- .../BlockScripting/BlockSyntaxValidator.cs | 4 +- .../BlockScripting/BlueprintDebugger.cs | 4 +- .../BlockScripting/BuiltinFunctionRegistry.cs | 4 +- .../BlockScripting/CSCompiler.cs | 12 +- .../BlockScripting/CSEmitContext.cs | 6 +- .../BlockScripting/CompiledScriptEntry.cs | 4 +- .../BlockScripting/CompiledScriptMeta.cs | 4 +- .../HelperFunctionCodeGenerator.cs | 4 +- .../IBuiltinFunctionDefinition.cs | 8 +- .../BlockScripting/ICompiledBlockScript.cs | 2 +- .../ScriptCompilationBackend.cs | 8 +- .../ScriptPersistenceManager.cs | 4 +- .../Blueprint/BlueprintRenderDataService.cs | 6 +- .../Blueprint/BlueprintService.cs | 18 +-- .../KitX.Workflow}/Blueprint/LayoutService.cs | 18 +-- .../Blueprint/NodeExportHelper.cs | 12 +- .../KitX.Workflow}/Blueprint/NodeRegistry.cs | 6 +- .../BuiltinFunctions/BranchFunction.cs | 12 +- .../BuiltinFunctions/BreakFunction.cs | 10 +- .../CreateWorkflowFunction.cs | 16 +-- .../BuiltinFunctions/FlipFunction.cs | 12 +- .../BuiltinFunctions/GetFunction.cs | 10 +- .../GetPluginInfoByNameFunction.cs | 16 +-- .../BuiltinFunctions/InstallPluginFunction.cs | 16 +-- .../BuiltinFunctions/JsonGetFieldFunction.cs | 12 +- .../ListPluginNamesFunction.cs | 18 +-- .../BuiltinFunctions/ListWorkflowsFunction.cs | 18 +-- .../BuiltinFunctions/LoopFunction.cs | 12 +- .../BuiltinFunctions/PauseFunction.cs | 12 +- .../BuiltinFunctions/PluginCallFunction.cs | 12 +- .../PluginCallWithTargetFunction.cs | 12 +- .../BuiltinFunctions/PrintFunction.cs | 12 +- .../BuiltinFunctions/ReadTextFileFunction.cs | 12 +- .../BuiltinFunctions/RunWorkflowFunction.cs | 16 +-- .../BuiltinFunctions/SetFunction.cs | 10 +- .../BuiltinFunctions/StartPluginFunction.cs | 16 +-- .../BuiltinFunctions/StopPluginFunction.cs | 16 +-- .../BuiltinFunctions/StopWorkflowFunction.cs | 16 +-- .../BuiltinFunctions/ToLoopCondFunction.cs | 12 +- .../BuiltinFunctions/TryGetDeviceFunction.cs | 20 +-- .../BuiltinFunctions/WriteTextFileFunction.cs | 12 +- .../CFG/BlueprintDebugContext.cs | 2 +- .../KitX.Workflow}/CFG/CFGBlock.cs | 2 +- .../KitX.Workflow}/CFG/CFGEdge.cs | 2 +- .../KitX.Workflow}/CFG/CFGStatement.cs | 2 +- .../KitX.Workflow}/CFG/ConstDeclaration.cs | 2 +- .../KitX.Workflow}/CFG/ControlFlowGraph.cs | 4 +- .../KitX.Workflow}/ConstantValueRewriter.cs | 4 +- .../Contract/IBlockScriptExecutor.cs | 41 ++++++ .../Contract/IBlockScriptPipeline.cs | 101 +++++++++++++ .../Contract/IBlockScriptPipelineService.cs | 45 ++++++ .../Contract/IBlueprintConverters.cs | 46 ++++++ .../KitX.Workflow/Contract/ILayoutService.cs | 10 ++ .../Contract/INodeExportStrategy.cs | 89 ++++++++++++ .../KitX.Workflow/Contract/IPluginManager.cs | 41 ++++++ .../Contract/Models/BlockDefinition.cs | 45 ++++++ .../Contract/Models/BlockScript.cs | 94 ++++++++++++ .../Contract/Models/BlockScriptModels.cs | 32 +++++ .../Contract/Models/FlowControlType.cs | 32 +++++ .../Contract/Models/PluginCallInfo.cs | 59 ++++++++ .../Models/Results/BlockExecutionResult.cs | 48 +++++++ .../Models/Results/BlockScriptParseResult.cs | 27 ++++ .../Models/Statements/BlockStatement.cs | 24 ++++ .../Models/Statements/ExpressionStatement.cs | 12 ++ .../Models/Statements/FlowControlStatement.cs | 51 +++++++ .../VariableDeclarationStatement.cs | 12 ++ .../Contract/Models/VariableDeclaration.cs | 27 ++++ .../Conversion/BP2CFGConverter.cs | 48 +++---- .../Conversion/BS2CFGConverter.cs | 12 +- .../Conversion/BlockScriptSerializer.cs | 12 +- .../BlockScriptToBlueprintConverter.cs | 14 +- .../BlueprintToBlockScriptConverter.cs | 16 +-- .../BuiltinFunctionExportStrategyAdapter.cs | 10 +- .../Conversion/CFG2BPConverter.cs | 12 +- .../Conversion/CFG2BSConverter.cs | 12 +- .../Conversion/CFG2CSGenerator.cs | 16 +-- .../Conversion/CFGConditionDuplicator.cs | 2 +- .../KitX.Workflow}/Conversion/CFGPipeline.cs | 12 +- .../Conversion/ConversionContext.cs | 12 +- .../Conversion/DataEdgeBuilder.cs | 12 +- .../KitX.Workflow}/Conversion/ExprUtils.cs | 10 +- .../Conversion/PipelineAssembler.cs | 16 +-- .../Conversion/PipelineContext.cs | 10 +- .../KitX.Workflow/GlobalUsings.cs | 8 ++ .../Hosting/ServiceCollectionExtensions.cs | 135 ++++++++++++++++++ .../KitX.Workflow/Hosting/ServiceLocator.cs | 72 ++++++++++ .../KitX.Workflow}/KcsFileService.cs | 4 +- .../KitX.Workflow/KitX.Workflow.csproj | 47 ++++++ .../KitX.Workflow}/RealPluginManager.cs | 47 ++---- .../KitX.Workflow}/TriggerManager.cs | 32 ++--- .../KitX.Workflow}/WorkflowCase.cs | 4 +- .../WorkflowManagementService.cs | 10 +- .../KitX.Workflow}/WorkflowOutput.cs | 4 +- .../KitX.Workflow}/WorkflowPluginService.cs | 16 ++- .../KitX.Workflow}/WorkflowRuntimeState.cs | 6 +- .../KitX.Workflow}/WorkflowScriptService.cs | 58 ++++---- .../KitX.Workflow}/WorkflowStorageService.cs | 16 +-- KitX Standard | 2 +- KitX.sln | 26 ++++ 112 files changed, 1609 insertions(+), 620 deletions(-) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/BlockScriptServiceImpl.cs (94%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/BlockScripting/BlockScopeManager.cs (98%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/BlockScripting/BlockScriptExecutionGlobals.cs (98%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/BlockScripting/BlockScriptExecutor.cs (98%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/BlockScripting/BlockScriptParser.cs (99%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/BlockScripting/BlockScriptWellKnown.cs (97%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/BlockScripting/BlockStatementExtractor.cs (98%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/BlockScripting/BlockStructureRecognizer.cs (97%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/BlockScripting/BlockSyntaxValidator.cs (98%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/BlockScripting/BlueprintDebugger.cs (97%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/BlockScripting/BuiltinFunctionRegistry.cs (97%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/BlockScripting/CSCompiler.cs (96%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/BlockScripting/CSEmitContext.cs (97%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/BlockScripting/CompiledScriptEntry.cs (96%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/BlockScripting/CompiledScriptMeta.cs (87%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/BlockScripting/HelperFunctionCodeGenerator.cs (97%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/BlockScripting/IBuiltinFunctionDefinition.cs (97%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/BlockScripting/ICompiledBlockScript.cs (92%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/BlockScripting/ScriptCompilationBackend.cs (95%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/BlockScripting/ScriptPersistenceManager.cs (99%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/Blueprint/BlueprintRenderDataService.cs (87%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/Blueprint/BlueprintService.cs (85%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/Blueprint/LayoutService.cs (96%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/Blueprint/NodeExportHelper.cs (92%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/Blueprint/NodeRegistry.cs (96%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/BuiltinFunctions/BranchFunction.cs (94%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/BuiltinFunctions/BreakFunction.cs (87%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/BuiltinFunctions/CreateWorkflowFunction.cs (89%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/BuiltinFunctions/FlipFunction.cs (95%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/BuiltinFunctions/GetFunction.cs (94%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/BuiltinFunctions/GetPluginInfoByNameFunction.cs (90%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/BuiltinFunctions/InstallPluginFunction.cs (84%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/BuiltinFunctions/JsonGetFieldFunction.cs (95%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/BuiltinFunctions/ListPluginNamesFunction.cs (85%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/BuiltinFunctions/ListWorkflowsFunction.cs (86%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/BuiltinFunctions/LoopFunction.cs (94%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/BuiltinFunctions/PauseFunction.cs (89%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/BuiltinFunctions/PluginCallFunction.cs (92%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/BuiltinFunctions/PluginCallWithTargetFunction.cs (97%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/BuiltinFunctions/PrintFunction.cs (89%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/BuiltinFunctions/ReadTextFileFunction.cs (89%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/BuiltinFunctions/RunWorkflowFunction.cs (84%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/BuiltinFunctions/SetFunction.cs (93%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/BuiltinFunctions/StartPluginFunction.cs (86%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/BuiltinFunctions/StopPluginFunction.cs (86%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/BuiltinFunctions/StopWorkflowFunction.cs (84%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/BuiltinFunctions/ToLoopCondFunction.cs (95%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/BuiltinFunctions/TryGetDeviceFunction.cs (92%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/BuiltinFunctions/WriteTextFileFunction.cs (90%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/CFG/BlueprintDebugContext.cs (75%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/CFG/CFGBlock.cs (98%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/CFG/CFGEdge.cs (98%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/CFG/CFGStatement.cs (99%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/CFG/ConstDeclaration.cs (97%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/CFG/ControlFlowGraph.cs (98%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/ConstantValueRewriter.cs (98%) create mode 100644 KitX Clients/KitX Workflow/KitX.Workflow/Contract/IBlockScriptExecutor.cs create mode 100644 KitX Clients/KitX Workflow/KitX.Workflow/Contract/IBlockScriptPipeline.cs create mode 100644 KitX Clients/KitX Workflow/KitX.Workflow/Contract/IBlockScriptPipelineService.cs create mode 100644 KitX Clients/KitX Workflow/KitX.Workflow/Contract/IBlueprintConverters.cs create mode 100644 KitX Clients/KitX Workflow/KitX.Workflow/Contract/ILayoutService.cs create mode 100644 KitX Clients/KitX Workflow/KitX.Workflow/Contract/INodeExportStrategy.cs create mode 100644 KitX Clients/KitX Workflow/KitX.Workflow/Contract/IPluginManager.cs create mode 100644 KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/BlockDefinition.cs create mode 100644 KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/BlockScript.cs create mode 100644 KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/BlockScriptModels.cs create mode 100644 KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/FlowControlType.cs create mode 100644 KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/PluginCallInfo.cs create mode 100644 KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/Results/BlockExecutionResult.cs create mode 100644 KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/Results/BlockScriptParseResult.cs create mode 100644 KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/Statements/BlockStatement.cs create mode 100644 KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/Statements/ExpressionStatement.cs create mode 100644 KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/Statements/FlowControlStatement.cs create mode 100644 KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/Statements/VariableDeclarationStatement.cs create mode 100644 KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/VariableDeclaration.cs rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/Conversion/BP2CFGConverter.cs (96%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/Conversion/BS2CFGConverter.cs (98%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/Conversion/BlockScriptSerializer.cs (94%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/Conversion/BlockScriptToBlueprintConverter.cs (95%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/Conversion/BlueprintToBlockScriptConverter.cs (87%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/Conversion/BuiltinFunctionExportStrategyAdapter.cs (88%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/Conversion/CFG2BPConverter.cs (98%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/Conversion/CFG2BSConverter.cs (95%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/Conversion/CFG2CSGenerator.cs (98%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/Conversion/CFGConditionDuplicator.cs (98%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/Conversion/CFGPipeline.cs (93%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/Conversion/ConversionContext.cs (89%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/Conversion/DataEdgeBuilder.cs (97%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/Conversion/ExprUtils.cs (97%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/Conversion/PipelineAssembler.cs (95%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/Conversion/PipelineContext.cs (96%) create mode 100644 KitX Clients/KitX Workflow/KitX.Workflow/GlobalUsings.cs create mode 100644 KitX Clients/KitX Workflow/KitX.Workflow/Hosting/ServiceCollectionExtensions.cs create mode 100644 KitX Clients/KitX Workflow/KitX.Workflow/Hosting/ServiceLocator.cs rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/KcsFileService.cs (98%) create mode 100644 KitX Clients/KitX Workflow/KitX.Workflow/KitX.Workflow.csproj rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/RealPluginManager.cs (94%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/TriggerManager.cs (87%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/WorkflowCase.cs (91%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/WorkflowManagementService.cs (95%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/WorkflowOutput.cs (94%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/WorkflowPluginService.cs (93%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/WorkflowRuntimeState.cs (92%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/WorkflowScriptService.cs (88%) rename KitX Clients/{KitX Core/KitX.Core/Workflow => KitX Workflow/KitX.Workflow}/WorkflowStorageService.cs (97%) diff --git a/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs b/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs index 19766eb4..6e36cbbc 100644 --- a/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs +++ b/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs @@ -5,11 +5,13 @@ using Microsoft.Extensions.DependencyInjection; using KitX.Core.DI; using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.Blueprint; -using KitX.Core.Workflow.CFG; -using KitX.Core.Workflow.BlockScripting; +using KitX.Workflow.Blueprint; +using KitX.Workflow.CFG; +using KitX.Workflow.BlockScripting; +using KitX.Workflow.Contract; +using KitX.Workflow.Contract.Models; +using KitX.Workflow.Conversion; -using KitX.Core.Workflow.Conversion; namespace KitX.Core.BluePrint.Test; public class Program diff --git a/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs b/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs index cf406e26..a9d0df73 100644 --- a/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs +++ b/KitX Clients/KitX Core/KitX.Core/DI/CoreServiceCollectionExtensions.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; using KitX.Core.Contract.Activity; using KitX.Core.Contract.Announcement; using KitX.Core.Contract.Configuration; @@ -21,13 +21,10 @@ using KitX.Core.Security; using KitX.Core.Statistics; using KitX.Core.Tasks; -using KitX.Core.Workflow; -using KitX.Core.Workflow.BlockScripting; -using KitX.Core.Workflow.Blueprint; using KitX.Core.Event; +using KitX.Workflow.Hosting; using Serilog; -using KitX.Core.Workflow.Conversion; namespace KitX.Core.DI; /// @@ -60,21 +57,6 @@ public static IServiceCollection AddCoreServices(this IServiceCollection service Log.Information("Registering IPluginService..."); services.AddSingleton(); - // Workflow Services - Log.Information("Registering workflow services..."); - // Individual service implementations (WorkflowScriptService facade used for backward compatibility) - // IBlockScriptService is created via factory to inject RealPluginManager from DI - services.AddSingleton(provider => - { - var state = WorkflowScriptService.RuntimeState; - var rpm = provider.GetRequiredService(); - var service = new BlockScriptServiceImpl(state, rpm); - Log.Information("[DI] IBlockScriptService created with RealPluginManager. HashCode: {HashCode}", rpm.GetHashCode()); - return service; - }); - services.AddSingleton(sp => WorkflowScriptService.PluginServiceInstance); - services.AddSingleton(sp => WorkflowScriptService.ManagementServiceInstance); - // Activity Services Log.Information("Registering IActivityService..."); services.AddSingleton(); @@ -116,106 +98,15 @@ public static IServiceCollection AddCoreServices(this IServiceCollection service Log.Information("Registering IDeviceHttpClient..."); services.AddSingleton(); - // RealPluginManager must be registered as singleton so that PluginsServer and WorkflowScriptService - // use the same instance. This ensures plugin connection events are properly received. - Log.Information("Registering RealPluginManager..."); - services.AddSingleton(provider => - { - var pluginServer = provider.GetRequiredService(); - var eventService = provider.GetRequiredService(); - var deviceDiscoveryService = provider.GetRequiredService(); - var deviceServer = provider.GetRequiredService(); - return new RealPluginManager(pluginServer, eventService, deviceDiscoveryService, deviceServer, provider.GetRequiredService()); - }); - - // RealPluginManager is pre-resolved by the caller after BuildServiceProvider(). - // Phase 5: Announcement Service Log.Information("Registering IAnnouncementService..."); services.AddSingleton(); - // KCS File Services - Log.Information("Registering IKcsFileService..."); - services.AddSingleton(); - - // Block Script Services - Log.Information("Registering IBlockScriptParser..."); - services.AddSingleton(provider => - { - var funcRegistry = provider.GetService(); - var service = new KitX.Core.Workflow.BlockScripting.BlockScriptParser(funcRegistry); - return service; - }); - - Log.Information("Registering IBlockScriptExecutor..."); - services.AddSingleton(provider => - { - var service = new KitX.Core.Workflow.BlockScripting.BlockScriptExecutor(); - // Resolve RealPluginManager from DI to ensure same instance - try - { - var pluginManager = provider.GetRequiredService(); - service.SetPluginManager(pluginManager); - Log.Information("[DI] IBlockScriptExecutor: RealPluginManager HashCode = {HashCode}", pluginManager.GetHashCode()); - } - catch (Exception ex) - { - Log.Error(ex, "[DI] Could not initialize BlockScriptExecutor with RealPluginManager from DI container"); - } - return service; - }); - - Log.Information("Registering IBlockScopeManager..."); - services.AddSingleton(); - - // Blueprint Sub-services (must be registered before IBlueprintService) - Log.Information("Registering Blueprint sub-services..."); - - // Discover and register all IBuiltinFunctionDefinition implementations - var functionRegistry = BuiltinFunctionRegistry.Discover(typeof(BuiltinFunctionRegistry).Assembly); - services.AddSingleton(functionRegistry); - - // NodeRegistry with function registry for dynamic node creation - services.AddSingleton(provider => - { - var reg = provider.GetRequiredService(); - return new NodeRegistry(reg); - }); - - services.AddSingleton(); - services.AddSingleton(); - - // Blueprint Converters - Log.Information("Registering IBlockScriptToBlueprintConverter..."); - services.AddSingleton(provider => - { - var parser = provider.GetRequiredService(); - var nodeRegistry = provider.GetRequiredService(); - var layoutService = provider.GetRequiredService(); - var funcRegistry = provider.GetService(); - return new BlockScriptToBlueprintConverter(parser, nodeRegistry, layoutService, funcRegistry!); - }); - Log.Information("Registering IBlueprintToBlockScriptConverter..."); - services.AddSingleton(); - - // Blueprint Export Strategies — all auto-registered from IBuiltinFunctionDefinition implementations - Log.Information("Registering auto-discovered builtin function export strategies..."); - foreach (var def in functionRegistry.AllDefinitions) - { - services.AddSingleton(new BuiltinFunctionExportStrategyAdapter(def)); - } - - // Blueprint Services - Log.Information("Registering IBlueprintService..."); - services.AddSingleton(); - - // Workflow Storage Service - Log.Information("Registering IWorkflowStorageService..."); - services.AddSingleton(); - - // Trigger Manager - Log.Information("Registering TriggerManager..."); - services.AddSingleton(); + // Workflow Services — full graph registered by the KitX.Workflow library. + // This includes RealPluginManager (registered as a singleton so PluginsServer and + // WorkflowScriptService share the same instance and receive plugin connection events; + // the caller pre-resolves it after BuildServiceProvider()). + services.AddKitXWorkflow(); // IMPORTANT: Do NOT call BuildServiceProvider() here. // The caller is responsible for building the single IServiceProvider and passing it diff --git a/KitX Clients/KitX Core/KitX.Core/Device/DeviceHttpClient.cs b/KitX Clients/KitX Core/KitX.Core/Device/DeviceHttpClient.cs index 23e0801e..91f25b7c 100644 --- a/KitX Clients/KitX Core/KitX.Core/Device/DeviceHttpClient.cs +++ b/KitX Clients/KitX Core/KitX.Core/Device/DeviceHttpClient.cs @@ -1,37 +1,18 @@ using System.Text; using System.Text.Json; using System.Text.Json.Serialization; +using KitX.Core.Contract.Device; using KitX.Shared.CSharp.Device; using KitX.Shared.CSharp.WebCommand; using Serilog; namespace KitX.Core.Device; -/// -/// Interface for device HTTP client — sends requests to remote DevicesServer instances. -/// Used for cross-device plugin invocation via the /Api/V1/Plugin/Invoke endpoint. -/// -public interface IDeviceHttpClient -{ - /// - /// Invokes a plugin method on a remote device via HTTP POST to /Api/V1/Plugin/Invoke. - /// - /// Target device info (contains IPv4 and DevicesServerPort) - /// Valid session token for the target device - /// The Request object to send - /// Cancellation token - /// HTTP response from remote device, or null on network error - Task InvokePluginAsync( - DeviceInfo targetDevice, - string token, - Request request, - CancellationToken ct = default); -} - /// /// Device HTTP client implementation. /// Sends plugin invoke requests to remote DevicesServer over HTTP. /// Protocol compatible with legacy PluginControllerExtensions.RemoteInvoke. +/// Implements the contract now defined in KitX.Core.Contract. /// public class DeviceHttpClient : IDeviceHttpClient { diff --git a/KitX Clients/KitX Core/KitX.Core/KitX.Core.csproj b/KitX Clients/KitX Core/KitX.Core/KitX.Core.csproj index 4abf2fc4..4d5e15b9 100644 --- a/KitX Clients/KitX Core/KitX.Core/KitX.Core.csproj +++ b/KitX Clients/KitX Core/KitX.Core/KitX.Core.csproj @@ -29,6 +29,7 @@ + diff --git a/KitX Clients/KitX Dashboard b/KitX Clients/KitX Dashboard index 5514abad..cf980d08 160000 --- a/KitX Clients/KitX Dashboard +++ b/KitX Clients/KitX Dashboard @@ -1 +1 @@ -Subproject commit 5514abad213e046f385d6248f3d5d79455ca5e30 +Subproject commit cf980d0889925900824839d4afe7c032336f4150 diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScriptServiceImpl.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScriptServiceImpl.cs similarity index 94% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BlockScriptServiceImpl.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/BlockScriptServiceImpl.cs index aad46814..3b48190c 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScriptServiceImpl.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScriptServiceImpl.cs @@ -1,14 +1,17 @@ -using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.BlockScripting; +using KitX.Core.Contract.Workflow; +using KitX.Workflow.BlockScripting; +using KitX.Workflow.Contract; using Serilog; -namespace KitX.Core.Workflow; +namespace KitX.Workflow; /// /// BlockScript parsing and execution service. -/// Implements IBlockScriptService. +/// Implements both the public IBlockScriptService (Dashboard-facing, source-string +/// based methods) and the internal IBlockScriptPipelineService (parsed-model based +/// methods consumed only by the workflow pipeline). /// -internal class BlockScriptServiceImpl : IBlockScriptService +internal class BlockScriptServiceImpl : IBlockScriptService, IBlockScriptPipelineService { private readonly WorkflowRuntimeState _state; private RealPluginManager? _realPluginManager; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScopeManager.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScopeManager.cs similarity index 98% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScopeManager.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScopeManager.cs index 82a0ba59..ec47f037 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScopeManager.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScopeManager.cs @@ -1,6 +1,6 @@ -using KitX.Core.Contract.Workflow; +using KitX.Core.Contract.Workflow; -namespace KitX.Core.Workflow.BlockScripting; +namespace KitX.Workflow.BlockScripting; /// /// Block scope manager implementation - manages variable scoping for block scripts diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutionGlobals.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptExecutionGlobals.cs similarity index 98% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutionGlobals.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptExecutionGlobals.cs index b54bd4d2..ea8a46f4 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutionGlobals.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptExecutionGlobals.cs @@ -1,6 +1,6 @@ -using KitX.Core.Contract.Workflow; +using KitX.Core.Contract.Workflow; -namespace KitX.Core.Workflow.BlockScripting; +namespace KitX.Workflow.BlockScripting; /// /// Script globals for block script execution — 仅保留核心手脚架。 diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutor.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptExecutor.cs similarity index 98% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutor.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptExecutor.cs index 95389970..16f58211 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptExecutor.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptExecutor.cs @@ -1,10 +1,10 @@ -using System.Diagnostics; +using System.Diagnostics; using KitX.Core.Contract.Workflow; using Serilog; -using KitX.Core.Workflow.CFG; +using KitX.Workflow.CFG; -using KitX.Core.Workflow.Conversion; -namespace KitX.Core.Workflow.BlockScripting; +using KitX.Workflow.Conversion; +namespace KitX.Workflow.BlockScripting; /// /// Block script executor using full-script assembly compilation. diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptParser.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptParser.cs similarity index 99% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptParser.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptParser.cs index 8e0e5f35..679ec825 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptParser.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptParser.cs @@ -1,7 +1,7 @@ -using KitX.Core.Contract.Workflow; +using KitX.Core.Contract.Workflow; using Serilog; -namespace KitX.Core.Workflow.BlockScripting; +namespace KitX.Workflow.BlockScripting; /// /// Block script parser implementation. diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptWellKnown.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptWellKnown.cs similarity index 97% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptWellKnown.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptWellKnown.cs index f506b014..58934b25 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockScriptWellKnown.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptWellKnown.cs @@ -1,4 +1,4 @@ -namespace KitX.Core.Workflow.BlockScripting; +namespace KitX.Workflow.BlockScripting; /// /// Well-known string constants for the BlockScript/Blueprint pipeline. diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockStatementExtractor.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockStatementExtractor.cs similarity index 98% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockStatementExtractor.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockStatementExtractor.cs index bb916afd..45cca35d 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockStatementExtractor.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockStatementExtractor.cs @@ -1,13 +1,13 @@ -using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; using Serilog; -using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Blocks; -using KitX.Core.Workflow.Conversion; +using static KitX.Workflow.BlockScripting.BlockScriptWellKnown.Blocks; +using KitX.Workflow.Conversion; -namespace KitX.Core.Workflow.BlockScripting; +namespace KitX.Workflow.BlockScripting; /// /// Extracts statements and variable declarations from validated syntax trees. diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockStructureRecognizer.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockStructureRecognizer.cs similarity index 97% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockStructureRecognizer.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockStructureRecognizer.cs index e47fa20a..d4e04170 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockStructureRecognizer.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockStructureRecognizer.cs @@ -1,10 +1,10 @@ -using Microsoft.CodeAnalysis.Text; +using Microsoft.CodeAnalysis.Text; using KitX.Core.Contract.Workflow; using Serilog; -using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Blocks; +using static KitX.Workflow.BlockScripting.BlockScriptWellKnown.Blocks; -namespace KitX.Core.Workflow.BlockScripting; +namespace KitX.Workflow.BlockScripting; /// /// Recognizes block structure using source text scanning. diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockSyntaxValidator.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockSyntaxValidator.cs similarity index 98% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockSyntaxValidator.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockSyntaxValidator.cs index 8a4b604b..4be34923 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlockSyntaxValidator.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockSyntaxValidator.cs @@ -1,9 +1,9 @@ -using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; -namespace KitX.Core.Workflow.BlockScripting; +namespace KitX.Workflow.BlockScripting; /// /// Validates pure C# code within recognized blocks using Roslyn. diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlueprintDebugger.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlueprintDebugger.cs similarity index 97% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlueprintDebugger.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlueprintDebugger.cs index 67ba71bb..27f43930 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BlueprintDebugger.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlueprintDebugger.cs @@ -1,8 +1,8 @@ -using System.Collections.Concurrent; +using System.Collections.Concurrent; using KitX.Core.Contract.Workflow; using Serilog; -namespace KitX.Core.Workflow.BlockScripting; +namespace KitX.Workflow.BlockScripting; public class BlueprintDebugger : IBlueprintDebugController { diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctionRegistry.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BuiltinFunctionRegistry.cs similarity index 97% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctionRegistry.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BuiltinFunctionRegistry.cs index 8f2d3014..b9c3cf9a 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/BuiltinFunctionRegistry.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BuiltinFunctionRegistry.cs @@ -1,7 +1,7 @@ -using System.Reflection; +using System.Reflection; using Serilog; -namespace KitX.Core.Workflow.BlockScripting; +namespace KitX.Workflow.BlockScripting; /// /// 内置函数定义的中央注册中心。通过反射自动发现所有 diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/CSCompiler.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/CSCompiler.cs similarity index 96% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/CSCompiler.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/CSCompiler.cs index 4ba13a9f..f1dbf315 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/CSCompiler.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/CSCompiler.cs @@ -1,9 +1,9 @@ -using KitX.Core.Contract.Workflow; +using KitX.Core.Contract.Workflow; using Serilog; -using KitX.Core.Workflow.Conversion; -using KitX.Core.Workflow.CFG; +using KitX.Workflow.Conversion; +using KitX.Workflow.CFG; -namespace KitX.Core.Workflow.BlockScripting; +namespace KitX.Workflow.BlockScripting; /// /// Compiles an entire into a .NET assembly using Roslyn @@ -104,7 +104,7 @@ public CSCompiler() // Phase 4: Load into collectible ALContext and instantiate var alc = new CollectibleAssemblyLoadContext(hash); var loadedAssembly = alc.LoadFromStream(assembly); - var typeName = $"KitX.Core.Workflow.BlockScripting.Generated.CompiledScript_{hash}"; + var typeName = $"KitX.Workflow.BlockScripting.Generated.CompiledScript_{hash}"; var scriptType = loadedAssembly.GetType(typeName); if (scriptType == null) { @@ -180,7 +180,7 @@ public CSCompiler() var alc = new CollectibleAssemblyLoadContext(hash); var loadedAssembly = alc.LoadFromStream(assembly); - var typeName = $"KitX.Core.Workflow.BlockScripting.Generated.CompiledScript_{hash}"; + var typeName = $"KitX.Workflow.BlockScripting.Generated.CompiledScript_{hash}"; var scriptType = loadedAssembly.GetType(typeName); if (scriptType == null) { alc.Unload(); return null; } diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/CSEmitContext.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/CSEmitContext.cs similarity index 97% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/CSEmitContext.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/CSEmitContext.cs index 18505b8a..eb40551a 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/CSEmitContext.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/CSEmitContext.cs @@ -1,10 +1,10 @@ using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; -using KitX.Core.Workflow.CFG; -using KitX.Core.Workflow.Conversion; +using KitX.Workflow.CFG; +using KitX.Workflow.Conversion; -namespace KitX.Core.Workflow.BlockScripting; +namespace KitX.Workflow.BlockScripting; /// /// Context passed to during diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/CompiledScriptEntry.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/CompiledScriptEntry.cs similarity index 96% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/CompiledScriptEntry.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/CompiledScriptEntry.cs index 703accb8..4e98fd92 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/CompiledScriptEntry.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/CompiledScriptEntry.cs @@ -1,7 +1,7 @@ -using System.Runtime.Loader; +using System.Runtime.Loader; using Serilog; -namespace KitX.Core.Workflow.BlockScripting; +namespace KitX.Workflow.BlockScripting; /// /// Wraps a compiled script instance with its diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/CompiledScriptMeta.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/CompiledScriptMeta.cs similarity index 87% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/CompiledScriptMeta.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/CompiledScriptMeta.cs index 49ce95d5..ce783c4a 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/CompiledScriptMeta.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/CompiledScriptMeta.cs @@ -1,4 +1,4 @@ -namespace KitX.Core.Workflow.BlockScripting; +namespace KitX.Workflow.BlockScripting; /// /// Metadata for a persisted compiled script assembly on disk. @@ -24,7 +24,7 @@ internal class CompiledScriptMeta /// /// Full type name of the compiled script class in the assembly, - /// e.g. "KitX.Core.Workflow.BlockScripting.Generated.CompiledScript_1a2b3c4d". + /// e.g. "KitX.Workflow.BlockScripting.Generated.CompiledScript_1a2b3c4d". /// public string TypeName { get; set; } = string.Empty; } \ No newline at end of file diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/HelperFunctionCodeGenerator.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/HelperFunctionCodeGenerator.cs similarity index 97% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/HelperFunctionCodeGenerator.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/HelperFunctionCodeGenerator.cs index 70f44e24..e98578e5 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/HelperFunctionCodeGenerator.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/HelperFunctionCodeGenerator.cs @@ -1,7 +1,7 @@ -using System.Text; +using System.Text; using KitX.Core.Contract.Workflow; -namespace KitX.Core.Workflow.BlockScripting; +namespace KitX.Workflow.BlockScripting; /// /// Generates C# source code from HelperFunction definitions. diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/IBuiltinFunctionDefinition.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/IBuiltinFunctionDefinition.cs similarity index 97% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/IBuiltinFunctionDefinition.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/IBuiltinFunctionDefinition.cs index e9d47b36..61e627ab 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/IBuiltinFunctionDefinition.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/IBuiltinFunctionDefinition.cs @@ -1,9 +1,9 @@ -using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.Conversion; -using KitX.Core.Workflow.CFG; +using KitX.Workflow.Conversion; +using KitX.Workflow.CFG; -namespace KitX.Core.Workflow.BlockScripting; +namespace KitX.Workflow.BlockScripting; /// /// 自描述的内置函数定义接口。实现此接口的类会被 diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ICompiledBlockScript.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/ICompiledBlockScript.cs similarity index 92% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ICompiledBlockScript.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/ICompiledBlockScript.cs index d399f7e0..e50ea607 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ICompiledBlockScript.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/ICompiledBlockScript.cs @@ -1,4 +1,4 @@ -namespace KitX.Core.Workflow.BlockScripting; +namespace KitX.Workflow.BlockScripting; /// /// Represents a BlockScript compiled into a .NET assembly via Roslyn CSharpCompilation. diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptCompilationBackend.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/ScriptCompilationBackend.cs similarity index 95% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptCompilationBackend.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/ScriptCompilationBackend.cs index 400e68e6..f97778a8 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptCompilationBackend.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/ScriptCompilationBackend.cs @@ -1,4 +1,4 @@ -using System.Reflection; +using System.Reflection; using System.Runtime.Loader; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; @@ -6,7 +6,7 @@ using KitX.Core.Contract.Workflow; using Serilog; -namespace KitX.Core.Workflow.BlockScripting; +namespace KitX.Workflow.BlockScripting; /// /// Roslyn compilation backend — compiles @@ -75,9 +75,9 @@ internal static List GetCompilationReferences() var seedAssemblies = new Assembly[] { typeof(BlockScriptExecutionGlobals).Assembly, - typeof(KitX.Core.Contract.Workflow.BlockScript).Assembly, + typeof(KitX.Workflow.Contract.Models.BlockScript).Assembly, typeof(ICompiledBlockScript).Assembly, - typeof(KitX.Core.Contract.Workflow.PluginCallInfo).Assembly, + typeof(KitX.Workflow.Contract.Models.PluginCallInfo).Assembly, typeof(Microsoft.CSharp.RuntimeBinder.Binder).Assembly, typeof(object).Assembly, typeof(System.Collections.Generic.List<>).Assembly, diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptPersistenceManager.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/ScriptPersistenceManager.cs similarity index 99% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptPersistenceManager.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/ScriptPersistenceManager.cs index 1f312967..a5e9ad19 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BlockScripting/ScriptPersistenceManager.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/ScriptPersistenceManager.cs @@ -1,7 +1,7 @@ -using System.Text.Json; +using System.Text.Json; using Serilog; -namespace KitX.Core.Workflow.BlockScripting; +namespace KitX.Workflow.BlockScripting; /// /// Manages disk persistence of compiled script assemblies. diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintRenderDataService.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Blueprint/BlueprintRenderDataService.cs similarity index 87% rename from KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintRenderDataService.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Blueprint/BlueprintRenderDataService.cs index 05cdffa4..102ea758 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintRenderDataService.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Blueprint/BlueprintRenderDataService.cs @@ -1,7 +1,7 @@ -using KitX.Core.Contract.Workflow; +using KitX.Core.Contract.Workflow; using Serilog; -namespace KitX.Core.Workflow.Blueprint; +namespace KitX.Workflow.Blueprint; /// /// Classifies blueprint connections into Exec (execution flow) and Data (data flow) @@ -10,7 +10,7 @@ namespace KitX.Core.Workflow.Blueprint; public class BlueprintRenderDataService : IBlueprintRenderDataService { /// - public BlueprintRenderData GetRenderData(Contract.Workflow.Blueprint blueprint) + public BlueprintRenderData GetRenderData(KitX.Core.Contract.Workflow.Blueprint blueprint) { var exec = new List(); var data = new List(); diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintService.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Blueprint/BlueprintService.cs similarity index 85% rename from KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintService.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Blueprint/BlueprintService.cs index f129dccf..9308f93f 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/BlueprintService.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Blueprint/BlueprintService.cs @@ -1,8 +1,8 @@ -using KitX.Core.Contract.Workflow; +using KitX.Core.Contract.Workflow; using Serilog; -using KitX.Core.Workflow.Conversion; -namespace KitX.Core.Workflow.Blueprint; +using KitX.Workflow.Conversion; +namespace KitX.Workflow.Blueprint; /// /// Blueprint service implementation @@ -23,10 +23,10 @@ public BlueprintService( _executor = executor; } - public Contract.Workflow.Blueprint CreateBlueprint() + public KitX.Core.Contract.Workflow.Blueprint CreateBlueprint() { Log.Information("Creating new Blueprint"); - return new Contract.Workflow.Blueprint + return new KitX.Core.Contract.Workflow.Blueprint { Name = "Untitled", CreatedAt = DateTime.Now, @@ -34,7 +34,7 @@ public Contract.Workflow.Blueprint CreateBlueprint() }; } - public Contract.Workflow.Blueprint? ImportFromBlockScript(string sourceCode, List? helperFunctions = null) + public KitX.Core.Contract.Workflow.Blueprint? ImportFromBlockScript(string sourceCode, List? helperFunctions = null) { try { @@ -50,7 +50,7 @@ public Contract.Workflow.Blueprint CreateBlueprint() } } - public string ExportToBlockScript(Contract.Workflow.Blueprint blueprint) + public string ExportToBlockScript(KitX.Core.Contract.Workflow.Blueprint blueprint) { try { @@ -64,7 +64,7 @@ public string ExportToBlockScript(Contract.Workflow.Blueprint blueprint) } } - public async Task ExecuteBlueprintAsync(Contract.Workflow.Blueprint blueprint) + public async Task ExecuteBlueprintAsync(KitX.Core.Contract.Workflow.Blueprint blueprint) { try { @@ -101,7 +101,7 @@ public async Task ExecuteBlueprintAsync(Contract.Wor } } - public Dictionary GetDebugNodeMapping(Contract.Workflow.Blueprint blueprint) + public Dictionary GetDebugNodeMapping(KitX.Core.Contract.Workflow.Blueprint blueprint) { try { diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/LayoutService.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Blueprint/LayoutService.cs similarity index 96% rename from KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/LayoutService.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Blueprint/LayoutService.cs index a2520a81..28c47ac2 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/LayoutService.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Blueprint/LayoutService.cs @@ -1,7 +1,7 @@ -using KitX.Core.Contract.Workflow; +using KitX.Core.Contract.Workflow; using Serilog; -namespace KitX.Core.Workflow.Blueprint; +namespace KitX.Workflow.Blueprint; /// /// Recursive subgraph layout engine. @@ -28,7 +28,7 @@ public class LayoutService : ILayoutService private const double DataNodeVSpacing = 150; /// - public void LayoutNodes(Contract.Workflow.Blueprint blueprint) + public void LayoutNodes(KitX.Core.Contract.Workflow.Blueprint blueprint) { if (blueprint.Nodes.Count == 0) return; @@ -76,7 +76,7 @@ public void LayoutNodes(Contract.Workflow.Blueprint blueprint) /// direction assignment matches physical pin layout. /// private Dictionary> BuildExecAdjacencyMap( - Contract.Workflow.Blueprint blueprint) + KitX.Core.Contract.Workflow.Blueprint blueprint) { var map = new Dictionary>(); @@ -110,7 +110,7 @@ public void LayoutNodes(Contract.Workflow.Blueprint blueprint) private LayoutRegion? BuildRegionTree( string nodeId, Dictionary> execMap, - Contract.Workflow.Blueprint blueprint, + KitX.Core.Contract.Workflow.Blueprint blueprint, HashSet visited, HashSet placed) { @@ -183,7 +183,7 @@ public void LayoutNodes(Contract.Workflow.Blueprint blueprint) /// /// Places data-only nodes (Const, Variable) in a left sidebar column. /// - private void PlaceDataNodes(Contract.Workflow.Blueprint blueprint, HashSet placed) + private void PlaceDataNodes(KitX.Core.Contract.Workflow.Blueprint blueprint, HashSet placed) { var dataNodes = blueprint.Nodes.Where(n => !placed.Contains(n.Id)).ToList(); if (dataNodes.Count == 0) return; @@ -249,7 +249,7 @@ private abstract class LayoutRegion public double MeasuredWidth { get; protected set; } public double MeasuredHeight { get; protected set; } public abstract void Measure(double availableWidth = MaxRowWidth); - public abstract void Arrange(double x, double y, Contract.Workflow.Blueprint bp); + public abstract void Arrange(double x, double y, KitX.Core.Contract.Workflow.Blueprint bp); } /// @@ -317,7 +317,7 @@ public override void Measure(double availableWidth = MaxRowWidth) } } - public override void Arrange(double x, double y, Contract.Workflow.Blueprint bp) + public override void Arrange(double x, double y, KitX.Core.Contract.Workflow.Blueprint bp) { double currentY = y; @@ -436,7 +436,7 @@ public override void Measure(double availableWidth = MaxRowWidth) + lowerBranchHeight; } - public override void Arrange(double x, double y, Contract.Workflow.Blueprint bp) + public override void Arrange(double x, double y, KitX.Core.Contract.Workflow.Blueprint bp) { // Read the fork node's actual position (placed by parent LinearRegion) var forkNode = bp.GetNodeById(ForkNodeId); diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeExportHelper.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Blueprint/NodeExportHelper.cs similarity index 92% rename from KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeExportHelper.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Blueprint/NodeExportHelper.cs index 0a9bdcde..06b50d6c 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeExportHelper.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Blueprint/NodeExportHelper.cs @@ -1,8 +1,8 @@ -using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.Conversion; -using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Pins; +using KitX.Core.Contract.Workflow; +using KitX.Workflow.Conversion; +using static KitX.Workflow.BlockScripting.BlockScriptWellKnown.Pins; -namespace KitX.Core.Workflow.Blueprint; +namespace KitX.Workflow.Blueprint; /// /// Implements INodeExportHelper — resolves input values for export strategies @@ -12,10 +12,10 @@ namespace KitX.Core.Workflow.Blueprint; internal class NodeExportHelper : INodeExportHelper { /// - public Contract.Workflow.Blueprint Blueprint { get; private set; } = null!; + public KitX.Core.Contract.Workflow.Blueprint Blueprint { get; private set; } = null!; /// Sets the current blueprint and context for resolution - public void SetContext(Contract.Workflow.Blueprint blueprint, ConversionContext? ctx) + public void SetContext(KitX.Core.Contract.Workflow.Blueprint blueprint, ConversionContext? ctx) { Blueprint = blueprint; _currentCtx = ctx; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeRegistry.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Blueprint/NodeRegistry.cs similarity index 96% rename from KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeRegistry.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Blueprint/NodeRegistry.cs index 6ddab745..d60363c5 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Blueprint/NodeRegistry.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Blueprint/NodeRegistry.cs @@ -1,8 +1,8 @@ -using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.BlockScripting; +using KitX.Core.Contract.Workflow; +using KitX.Workflow.BlockScripting; using Serilog; -namespace KitX.Core.Workflow.Blueprint; +namespace KitX.Workflow.Blueprint; /// /// Unified registry for node type creation and metadata. diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/BranchFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/BranchFunction.cs similarity index 94% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/BranchFunction.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/BranchFunction.cs index 094373c7..d8fa3160 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/BranchFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/BranchFunction.cs @@ -1,10 +1,10 @@ -using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.Conversion; -using KitX.Core.Workflow.CFG; -using KitX.Core.Workflow.BlockScripting; +using KitX.Workflow.Conversion; +using KitX.Workflow.CFG; +using KitX.Workflow.BlockScripting; -namespace KitX.Core.Workflow.BuiltinFunctions +namespace KitX.Workflow.BuiltinFunctions { /// /// Branch 内置函数 — 条件分支控制流。 @@ -96,7 +96,7 @@ private static string GetStringLiteral(ExpressionSyntax expr) => } } -namespace KitX.Core.Workflow.BlockScripting +namespace KitX.Workflow.BlockScripting { public partial class BlockScriptExecutionGlobals { diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/BreakFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/BreakFunction.cs similarity index 87% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/BreakFunction.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/BreakFunction.cs index 1a323548..97963aa0 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/BreakFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/BreakFunction.cs @@ -1,10 +1,10 @@ -using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.Conversion; -using KitX.Core.Workflow.CFG; -using KitX.Core.Workflow.BlockScripting; +using KitX.Workflow.Conversion; +using KitX.Workflow.CFG; +using KitX.Workflow.BlockScripting; -namespace KitX.Core.Workflow.BuiltinFunctions; +namespace KitX.Workflow.BuiltinFunctions; /// /// Break 内置函数 — 退出当前循环。 diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/CreateWorkflowFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/CreateWorkflowFunction.cs similarity index 89% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/CreateWorkflowFunction.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/CreateWorkflowFunction.cs index 44ece492..e1bbe466 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/CreateWorkflowFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/CreateWorkflowFunction.cs @@ -1,11 +1,11 @@ -using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; using Serilog; -using KitX.Core.Workflow.Conversion; -using KitX.Core.Workflow.CFG; -using KitX.Core.Workflow.BlockScripting; +using KitX.Workflow.Conversion; +using KitX.Workflow.CFG; +using KitX.Workflow.BlockScripting; -namespace KitX.Core.Workflow.BuiltinFunctions +namespace KitX.Workflow.BuiltinFunctions { public class CreateWorkflowFunction : IBuiltinFunctionDefinition { @@ -53,17 +53,17 @@ public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) } } -namespace KitX.Core.Workflow.BlockScripting +namespace KitX.Workflow.BlockScripting { public partial class BlockScriptExecutionGlobals { public string CreateWorkflow(string name, string blockScriptSource) { if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(blockScriptSource)) return ""; - if (!DI.ServiceHost.IsInitialized) return ""; + if (!ServiceLocator.IsInitialized) return ""; try { - var storage = DI.ServiceHost.GetRequiredService(); + var storage = ServiceLocator.GetRequiredService(); var workflowId = Guid.NewGuid().ToString(); var kcsData = new KcsFileFormat { diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/FlipFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/FlipFunction.cs similarity index 95% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/FlipFunction.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/FlipFunction.cs index 0d0f1690..a69a5d53 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/FlipFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/FlipFunction.cs @@ -1,10 +1,10 @@ -using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.Conversion; -using KitX.Core.Workflow.CFG; -using KitX.Core.Workflow.BlockScripting; +using KitX.Workflow.Conversion; +using KitX.Workflow.CFG; +using KitX.Workflow.BlockScripting; -namespace KitX.Core.Workflow.BuiltinFunctions +namespace KitX.Workflow.BuiltinFunctions { /// /// Flip 内置函数 — 交替路由控制流。每次执行时交替选择两个输出分支之一。 @@ -96,7 +96,7 @@ private static string GetStringLiteral(ExpressionSyntax expr) => } } -namespace KitX.Core.Workflow.BlockScripting +namespace KitX.Workflow.BlockScripting { // ────────────────────────────────────────────── // Partial class — Flip 的运行时方法和状态 diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/GetFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/GetFunction.cs similarity index 94% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/GetFunction.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/GetFunction.cs index 3d585e06..7f612b95 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/GetFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/GetFunction.cs @@ -1,10 +1,10 @@ -using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.Conversion; -using KitX.Core.Workflow.CFG; -using KitX.Core.Workflow.BlockScripting; +using KitX.Workflow.Conversion; +using KitX.Workflow.CFG; +using KitX.Workflow.BlockScripting; -namespace KitX.Core.Workflow.BuiltinFunctions; +namespace KitX.Workflow.BuiltinFunctions; /// /// Get builtin function — reads a variable value from global scope. diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/GetPluginInfoByNameFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/GetPluginInfoByNameFunction.cs similarity index 90% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/GetPluginInfoByNameFunction.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/GetPluginInfoByNameFunction.cs index e4411ef9..a3f51953 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/GetPluginInfoByNameFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/GetPluginInfoByNameFunction.cs @@ -1,13 +1,13 @@ -using System.Text.Json; +using System.Text.Json; using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Plugin; using KitX.Core.Contract.Workflow; using Serilog; -using KitX.Core.Workflow.Conversion; -using KitX.Core.Workflow.CFG; -using KitX.Core.Workflow.BlockScripting; +using KitX.Workflow.Conversion; +using KitX.Workflow.CFG; +using KitX.Workflow.BlockScripting; -namespace KitX.Core.Workflow.BuiltinFunctions +namespace KitX.Workflow.BuiltinFunctions { public class GetPluginInfoByNameFunction : IBuiltinFunctionDefinition { @@ -63,17 +63,17 @@ private static string StripQuotes(string s) } } -namespace KitX.Core.Workflow.BlockScripting +namespace KitX.Workflow.BlockScripting { public partial class BlockScriptExecutionGlobals { public string GetPluginInfoByName(string pluginName) { if (string.IsNullOrEmpty(pluginName)) return "{}"; - if (!DI.ServiceHost.IsInitialized) return "{}"; + if (!ServiceLocator.IsInitialized) return "{}"; try { - var pluginService = DI.ServiceHost.GetRequiredService(); + var pluginService = ServiceLocator.GetRequiredService(); var plugin = pluginService.GetInstalledPlugins() .FirstOrDefault(p => p.PluginInfo?.Name == pluginName); if (plugin?.PluginInfo == null) return "{}"; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/InstallPluginFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/InstallPluginFunction.cs similarity index 84% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/InstallPluginFunction.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/InstallPluginFunction.cs index d2c3d0b8..61d9efec 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/InstallPluginFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/InstallPluginFunction.cs @@ -1,12 +1,12 @@ -using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Plugin; using KitX.Core.Contract.Workflow; using Serilog; -using KitX.Core.Workflow.Conversion; -using KitX.Core.Workflow.CFG; -using KitX.Core.Workflow.BlockScripting; +using KitX.Workflow.Conversion; +using KitX.Workflow.CFG; +using KitX.Workflow.BlockScripting; -namespace KitX.Core.Workflow.BuiltinFunctions +namespace KitX.Workflow.BuiltinFunctions { public class InstallPluginFunction : IBuiltinFunctionDefinition { @@ -47,17 +47,17 @@ public class InstallPluginFunction : IBuiltinFunctionDefinition } } -namespace KitX.Core.Workflow.BlockScripting +namespace KitX.Workflow.BlockScripting { public partial class BlockScriptExecutionGlobals { public bool InstallPlugin(string kxpPath) { if (string.IsNullOrEmpty(kxpPath)) return false; - if (!DI.ServiceHost.IsInitialized) return false; + if (!ServiceLocator.IsInitialized) return false; try { - var pluginService = DI.ServiceHost.GetRequiredService(); + var pluginService = ServiceLocator.GetRequiredService(); return pluginService.ImportPluginAsync(kxpPath).GetAwaiter().GetResult(); } catch (Exception ex) diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/JsonGetFieldFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/JsonGetFieldFunction.cs similarity index 95% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/JsonGetFieldFunction.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/JsonGetFieldFunction.cs index 57214d64..35b187e8 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/JsonGetFieldFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/JsonGetFieldFunction.cs @@ -1,12 +1,12 @@ -using System.Text.Json; +using System.Text.Json; using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; using Serilog; -using KitX.Core.Workflow.Conversion; -using KitX.Core.Workflow.CFG; -using KitX.Core.Workflow.BlockScripting; +using KitX.Workflow.Conversion; +using KitX.Workflow.CFG; +using KitX.Workflow.BlockScripting; -namespace KitX.Core.Workflow.BuiltinFunctions +namespace KitX.Workflow.BuiltinFunctions { public class JsonGetFieldFunction : IBuiltinFunctionDefinition { @@ -64,7 +64,7 @@ private static string StripQuotes(string s) } } -namespace KitX.Core.Workflow.BlockScripting +namespace KitX.Workflow.BlockScripting { public partial class BlockScriptExecutionGlobals { diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/ListPluginNamesFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ListPluginNamesFunction.cs similarity index 85% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/ListPluginNamesFunction.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ListPluginNamesFunction.cs index 2038c5f4..74dd5297 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/ListPluginNamesFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ListPluginNamesFunction.cs @@ -1,13 +1,13 @@ -using System.Text.Json; +using System.Text.Json; using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Plugin; using KitX.Core.Contract.Workflow; using Serilog; -using KitX.Core.Workflow.Conversion; -using KitX.Core.Workflow.CFG; -using KitX.Core.Workflow.BlockScripting; +using KitX.Workflow.Conversion; +using KitX.Workflow.CFG; +using KitX.Workflow.BlockScripting; -namespace KitX.Core.Workflow.BuiltinFunctions +namespace KitX.Workflow.BuiltinFunctions { public class ListPluginNamesFunction : IBuiltinFunctionDefinition { @@ -46,20 +46,20 @@ public class ListPluginNamesFunction : IBuiltinFunctionDefinition } } -namespace KitX.Core.Workflow.BlockScripting +namespace KitX.Workflow.BlockScripting { public partial class BlockScriptExecutionGlobals { public string ListPluginNames() { - if (!DI.ServiceHost.IsInitialized) + if (!ServiceLocator.IsInitialized) { - Log.Warning("[BlockScriptGlobals] ListPluginNames: ServiceHost not initialized"); + Log.Warning("[BlockScriptGlobals] ListPluginNames: ServiceLocator not initialized"); return "[]"; } try { - var pluginService = DI.ServiceHost.GetRequiredService(); + var pluginService = ServiceLocator.GetRequiredService(); var plugins = pluginService.GetInstalledPlugins(); var names = plugins .Where(p => p.PluginInfo != null) diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/ListWorkflowsFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ListWorkflowsFunction.cs similarity index 86% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/ListWorkflowsFunction.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ListWorkflowsFunction.cs index 4bb1bba0..012ed872 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/ListWorkflowsFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ListWorkflowsFunction.cs @@ -1,12 +1,12 @@ -using System.Text.Json; +using System.Text.Json; using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; using Serilog; -using KitX.Core.Workflow.Conversion; -using KitX.Core.Workflow.CFG; -using KitX.Core.Workflow.BlockScripting; +using KitX.Workflow.Conversion; +using KitX.Workflow.CFG; +using KitX.Workflow.BlockScripting; -namespace KitX.Core.Workflow.BuiltinFunctions +namespace KitX.Workflow.BuiltinFunctions { public class ListWorkflowsFunction : IBuiltinFunctionDefinition { @@ -45,20 +45,20 @@ public class ListWorkflowsFunction : IBuiltinFunctionDefinition } } -namespace KitX.Core.Workflow.BlockScripting +namespace KitX.Workflow.BlockScripting { public partial class BlockScriptExecutionGlobals { public string ListWorkflows() { - if (!DI.ServiceHost.IsInitialized) + if (!ServiceLocator.IsInitialized) { - Log.Warning("[BlockScriptGlobals] ListWorkflows: ServiceHost not initialized"); + Log.Warning("[BlockScriptGlobals] ListWorkflows: ServiceLocator not initialized"); return "[]"; } try { - var storage = DI.ServiceHost.GetRequiredService(); + var storage = ServiceLocator.GetRequiredService(); var workflows = storage.DiscoverWorkflowsAsync().GetAwaiter().GetResult(); var info = workflows.Select(w => new { diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/LoopFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/LoopFunction.cs similarity index 94% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/LoopFunction.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/LoopFunction.cs index fb9c1ecc..cbe8497f 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/LoopFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/LoopFunction.cs @@ -1,10 +1,10 @@ -using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.Conversion; -using KitX.Core.Workflow.CFG; -using KitX.Core.Workflow.BlockScripting; +using KitX.Workflow.Conversion; +using KitX.Workflow.CFG; +using KitX.Workflow.BlockScripting; -namespace KitX.Core.Workflow.BuiltinFunctions +namespace KitX.Workflow.BuiltinFunctions { /// /// Loop 内置函数 — 循环控制流。 @@ -100,7 +100,7 @@ private static string GetStringLiteral(ExpressionSyntax expr) => } } -namespace KitX.Core.Workflow.BlockScripting +namespace KitX.Workflow.BlockScripting { public partial class BlockScriptExecutionGlobals { diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PauseFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PauseFunction.cs similarity index 89% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PauseFunction.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PauseFunction.cs index 0cc9cceb..8f2b961f 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PauseFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PauseFunction.cs @@ -1,10 +1,10 @@ -using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.Conversion; -using KitX.Core.Workflow.CFG; -using KitX.Core.Workflow.BlockScripting; +using KitX.Workflow.Conversion; +using KitX.Workflow.CFG; +using KitX.Workflow.BlockScripting; -namespace KitX.Core.Workflow.BuiltinFunctions +namespace KitX.Workflow.BuiltinFunctions { /// /// Pause 内置函数 — 暂停执行指定毫秒数。 @@ -53,7 +53,7 @@ public List EmitStatements(CFGStatement stmt, CSEmitContext ctx } } -namespace KitX.Core.Workflow.BlockScripting +namespace KitX.Workflow.BlockScripting { public partial class BlockScriptExecutionGlobals { diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PluginCallFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PluginCallFunction.cs similarity index 92% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PluginCallFunction.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PluginCallFunction.cs index 6d664987..a0b74035 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PluginCallFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PluginCallFunction.cs @@ -1,11 +1,11 @@ -using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; using Serilog; -using KitX.Core.Workflow.Conversion; -using KitX.Core.Workflow.CFG; -using KitX.Core.Workflow.BlockScripting; +using KitX.Workflow.Conversion; +using KitX.Workflow.CFG; +using KitX.Workflow.BlockScripting; -namespace KitX.Core.Workflow.BuiltinFunctions +namespace KitX.Workflow.BuiltinFunctions { /// /// PluginCall 内置函数 — 调用本机插件函数。 @@ -44,7 +44,7 @@ public List EmitStatements(CFGStatement stmt, CSEmitContext ctx } } -namespace KitX.Core.Workflow.BlockScripting +namespace KitX.Workflow.BlockScripting { public partial class BlockScriptExecutionGlobals { diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PluginCallWithTargetFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PluginCallWithTargetFunction.cs similarity index 97% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PluginCallWithTargetFunction.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PluginCallWithTargetFunction.cs index 0d3fa9f1..caedfd1c 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PluginCallWithTargetFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PluginCallWithTargetFunction.cs @@ -1,11 +1,11 @@ -using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; using Serilog; -using KitX.Core.Workflow.Conversion; -using KitX.Core.Workflow.CFG; -using KitX.Core.Workflow.BlockScripting; +using KitX.Workflow.Conversion; +using KitX.Workflow.CFG; +using KitX.Workflow.BlockScripting; -namespace KitX.Core.Workflow.BuiltinFunctions +namespace KitX.Workflow.BuiltinFunctions { /// /// PluginCallWithTarget 内置函数 — 调用远程设备上的插件。 @@ -181,7 +181,7 @@ private static string StripQuotes(string s) } } -namespace KitX.Core.Workflow.BlockScripting +namespace KitX.Workflow.BlockScripting { public partial class BlockScriptExecutionGlobals { diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PrintFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PrintFunction.cs similarity index 89% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PrintFunction.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PrintFunction.cs index 1fd63332..6a095b93 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/PrintFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PrintFunction.cs @@ -1,10 +1,10 @@ -using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.Conversion; -using KitX.Core.Workflow.CFG; -using KitX.Core.Workflow.BlockScripting; +using KitX.Workflow.Conversion; +using KitX.Workflow.CFG; +using KitX.Workflow.BlockScripting; -namespace KitX.Core.Workflow.BuiltinFunctions +namespace KitX.Workflow.BuiltinFunctions { /// /// Print 内置函数 — 输出值到控制台。 @@ -53,7 +53,7 @@ public List EmitStatements(CFGStatement stmt, CSEmitContext ctx } } -namespace KitX.Core.Workflow.BlockScripting +namespace KitX.Workflow.BlockScripting { public partial class BlockScriptExecutionGlobals { diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/ReadTextFileFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ReadTextFileFunction.cs similarity index 89% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/ReadTextFileFunction.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ReadTextFileFunction.cs index 32595888..5348877a 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/ReadTextFileFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ReadTextFileFunction.cs @@ -1,11 +1,11 @@ -using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; using Serilog; -using KitX.Core.Workflow.Conversion; -using KitX.Core.Workflow.CFG; -using KitX.Core.Workflow.BlockScripting; +using KitX.Workflow.Conversion; +using KitX.Workflow.CFG; +using KitX.Workflow.BlockScripting; -namespace KitX.Core.Workflow.BuiltinFunctions +namespace KitX.Workflow.BuiltinFunctions { public class ReadTextFileFunction : IBuiltinFunctionDefinition { @@ -46,7 +46,7 @@ public class ReadTextFileFunction : IBuiltinFunctionDefinition } } -namespace KitX.Core.Workflow.BlockScripting +namespace KitX.Workflow.BlockScripting { public partial class BlockScriptExecutionGlobals { diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/RunWorkflowFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/RunWorkflowFunction.cs similarity index 84% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/RunWorkflowFunction.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/RunWorkflowFunction.cs index 45820e97..c53ba4f7 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/RunWorkflowFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/RunWorkflowFunction.cs @@ -1,11 +1,11 @@ -using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; using Serilog; -using KitX.Core.Workflow.Conversion; -using KitX.Core.Workflow.CFG; -using KitX.Core.Workflow.BlockScripting; +using KitX.Workflow.Conversion; +using KitX.Workflow.CFG; +using KitX.Workflow.BlockScripting; -namespace KitX.Core.Workflow.BuiltinFunctions +namespace KitX.Workflow.BuiltinFunctions { public class RunWorkflowFunction : IBuiltinFunctionDefinition { @@ -46,17 +46,17 @@ public class RunWorkflowFunction : IBuiltinFunctionDefinition } } -namespace KitX.Core.Workflow.BlockScripting +namespace KitX.Workflow.BlockScripting { public partial class BlockScriptExecutionGlobals { public bool RunWorkflow(string workflowId) { if (string.IsNullOrEmpty(workflowId)) return false; - if (!DI.ServiceHost.IsInitialized) return false; + if (!ServiceLocator.IsInitialized) return false; try { - var wfService = DI.ServiceHost.GetRequiredService(); + var wfService = ServiceLocator.GetRequiredService(); return wfService.RunWorkflowAsync(workflowId).GetAwaiter().GetResult(); } catch (Exception ex) diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/SetFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/SetFunction.cs similarity index 93% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/SetFunction.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/SetFunction.cs index b6b294ed..14ab179f 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/SetFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/SetFunction.cs @@ -1,10 +1,10 @@ -using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.Conversion; -using KitX.Core.Workflow.CFG; -using KitX.Core.Workflow.BlockScripting; +using KitX.Workflow.Conversion; +using KitX.Workflow.CFG; +using KitX.Workflow.BlockScripting; -namespace KitX.Core.Workflow.BuiltinFunctions +namespace KitX.Workflow.BuiltinFunctions { /// /// Set builtin function — writes a value to a global variable. diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/StartPluginFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/StartPluginFunction.cs similarity index 86% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/StartPluginFunction.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/StartPluginFunction.cs index adef6ea3..5cfa963b 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/StartPluginFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/StartPluginFunction.cs @@ -1,12 +1,12 @@ -using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Plugin; using KitX.Core.Contract.Workflow; using Serilog; -using KitX.Core.Workflow.Conversion; -using KitX.Core.Workflow.CFG; -using KitX.Core.Workflow.BlockScripting; +using KitX.Workflow.Conversion; +using KitX.Workflow.CFG; +using KitX.Workflow.BlockScripting; -namespace KitX.Core.Workflow.BuiltinFunctions +namespace KitX.Workflow.BuiltinFunctions { public class StartPluginFunction : IBuiltinFunctionDefinition { @@ -47,17 +47,17 @@ public class StartPluginFunction : IBuiltinFunctionDefinition } } -namespace KitX.Core.Workflow.BlockScripting +namespace KitX.Workflow.BlockScripting { public partial class BlockScriptExecutionGlobals { public bool StartPlugin(string pluginName) { if (string.IsNullOrEmpty(pluginName)) return false; - if (!DI.ServiceHost.IsInitialized) return false; + if (!ServiceLocator.IsInitialized) return false; try { - var pluginService = DI.ServiceHost.GetRequiredService(); + var pluginService = ServiceLocator.GetRequiredService(); var plugin = pluginService.GetInstalledPlugins() .FirstOrDefault(p => p.PluginInfo?.Name == pluginName); if (plugin == null) return false; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/StopPluginFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/StopPluginFunction.cs similarity index 86% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/StopPluginFunction.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/StopPluginFunction.cs index 8c3cb271..c0c5ad17 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/StopPluginFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/StopPluginFunction.cs @@ -1,12 +1,12 @@ -using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Plugin; using KitX.Core.Contract.Workflow; using Serilog; -using KitX.Core.Workflow.Conversion; -using KitX.Core.Workflow.CFG; -using KitX.Core.Workflow.BlockScripting; +using KitX.Workflow.Conversion; +using KitX.Workflow.CFG; +using KitX.Workflow.BlockScripting; -namespace KitX.Core.Workflow.BuiltinFunctions +namespace KitX.Workflow.BuiltinFunctions { public class StopPluginFunction : IBuiltinFunctionDefinition { @@ -47,17 +47,17 @@ public class StopPluginFunction : IBuiltinFunctionDefinition } } -namespace KitX.Core.Workflow.BlockScripting +namespace KitX.Workflow.BlockScripting { public partial class BlockScriptExecutionGlobals { public bool StopPlugin(string pluginName) { if (string.IsNullOrEmpty(pluginName)) return false; - if (!DI.ServiceHost.IsInitialized) return false; + if (!ServiceLocator.IsInitialized) return false; try { - var pluginService = DI.ServiceHost.GetRequiredService(); + var pluginService = ServiceLocator.GetRequiredService(); var plugin = pluginService.GetInstalledPlugins() .FirstOrDefault(p => p.PluginInfo?.Name == pluginName); if (plugin == null) return false; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/StopWorkflowFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/StopWorkflowFunction.cs similarity index 84% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/StopWorkflowFunction.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/StopWorkflowFunction.cs index fc1af6cc..89da297b 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/StopWorkflowFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/StopWorkflowFunction.cs @@ -1,11 +1,11 @@ -using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; using Serilog; -using KitX.Core.Workflow.Conversion; -using KitX.Core.Workflow.CFG; -using KitX.Core.Workflow.BlockScripting; +using KitX.Workflow.Conversion; +using KitX.Workflow.CFG; +using KitX.Workflow.BlockScripting; -namespace KitX.Core.Workflow.BuiltinFunctions +namespace KitX.Workflow.BuiltinFunctions { public class StopWorkflowFunction : IBuiltinFunctionDefinition { @@ -46,17 +46,17 @@ public class StopWorkflowFunction : IBuiltinFunctionDefinition } } -namespace KitX.Core.Workflow.BlockScripting +namespace KitX.Workflow.BlockScripting { public partial class BlockScriptExecutionGlobals { public bool StopWorkflow(string workflowId) { if (string.IsNullOrEmpty(workflowId)) return false; - if (!DI.ServiceHost.IsInitialized) return false; + if (!ServiceLocator.IsInitialized) return false; try { - var wfService = DI.ServiceHost.GetRequiredService(); + var wfService = ServiceLocator.GetRequiredService(); return wfService.StopWorkflowAsync(workflowId).GetAwaiter().GetResult(); } catch (Exception ex) diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/ToLoopCondFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ToLoopCondFunction.cs similarity index 95% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/ToLoopCondFunction.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ToLoopCondFunction.cs index 749493e7..9a8d749f 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/ToLoopCondFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ToLoopCondFunction.cs @@ -1,10 +1,10 @@ -using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.Conversion; -using KitX.Core.Workflow.CFG; -using KitX.Core.Workflow.BlockScripting; +using KitX.Workflow.Conversion; +using KitX.Workflow.CFG; +using KitX.Workflow.BlockScripting; -namespace KitX.Core.Workflow.BuiltinFunctions +namespace KitX.Workflow.BuiltinFunctions { /// /// ToLoopCond 内置函数 — 标记循环体的结尾并返回循环条件块。 @@ -113,7 +113,7 @@ private static string GetStringLiteral(ExpressionSyntax expr) => } } -namespace KitX.Core.Workflow.BlockScripting +namespace KitX.Workflow.BlockScripting { // ────────────────────────────────────────────── // Partial class — ToLoopCond 的运行时方法 diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/TryGetDeviceFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/TryGetDeviceFunction.cs similarity index 92% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/TryGetDeviceFunction.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/TryGetDeviceFunction.cs index 4fd538c6..0d09d824 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/TryGetDeviceFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/TryGetDeviceFunction.cs @@ -1,13 +1,13 @@ -using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; using KitX.Core.Contract.Device; using KitX.Shared.CSharp.Device; using Serilog; -using KitX.Core.Workflow.Conversion; -using KitX.Core.Workflow.CFG; -using KitX.Core.Workflow.BlockScripting; +using KitX.Workflow.Conversion; +using KitX.Workflow.CFG; +using KitX.Workflow.BlockScripting; -namespace KitX.Core.Workflow.BuiltinFunctions +namespace KitX.Workflow.BuiltinFunctions { /// /// TryGetDevice 内置函数 — 根据设备名称查找已连接设备。 @@ -111,7 +111,7 @@ private static string StripQuotes(string s) } } -namespace KitX.Core.Workflow.BlockScripting +namespace KitX.Workflow.BlockScripting { public partial class BlockScriptExecutionGlobals { @@ -129,14 +129,14 @@ public partial class BlockScriptExecutionGlobals try { - if (!DI.ServiceHost.IsInitialized) + if (!ServiceLocator.IsInitialized) { - Log.Warning("[BlockScriptGlobals] TryGetDevice: ServiceHost not initialized"); + Log.Warning("[BlockScriptGlobals] TryGetDevice: ServiceLocator not initialized"); return null; } - var deviceServer = DI.ServiceHost.GetRequiredService(); - var discoveryService = DI.ServiceHost.GetRequiredService(); + var deviceServer = ServiceLocator.GetRequiredService(); + var discoveryService = ServiceLocator.GetRequiredService(); var signedInDevices = deviceServer.GetSignedInDevices(); diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/WriteTextFileFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/WriteTextFileFunction.cs similarity index 90% rename from KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/WriteTextFileFunction.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/WriteTextFileFunction.cs index 6ee0db8a..cb6e6fd4 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/BuiltinFunctions/WriteTextFileFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/WriteTextFileFunction.cs @@ -1,11 +1,11 @@ -using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; using Serilog; -using KitX.Core.Workflow.Conversion; -using KitX.Core.Workflow.CFG; -using KitX.Core.Workflow.BlockScripting; +using KitX.Workflow.Conversion; +using KitX.Workflow.CFG; +using KitX.Workflow.BlockScripting; -namespace KitX.Core.Workflow.BuiltinFunctions +namespace KitX.Workflow.BuiltinFunctions { public class WriteTextFileFunction : IBuiltinFunctionDefinition { @@ -47,7 +47,7 @@ public class WriteTextFileFunction : IBuiltinFunctionDefinition } } -namespace KitX.Core.Workflow.BlockScripting +namespace KitX.Workflow.BlockScripting { public partial class BlockScriptExecutionGlobals { diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/CFG/BlueprintDebugContext.cs b/KitX Clients/KitX Workflow/KitX.Workflow/CFG/BlueprintDebugContext.cs similarity index 75% rename from KitX Clients/KitX Core/KitX.Core/Workflow/CFG/BlueprintDebugContext.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/CFG/BlueprintDebugContext.cs index b4d1bbd7..aea104ae 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/CFG/BlueprintDebugContext.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/CFG/BlueprintDebugContext.cs @@ -1,4 +1,4 @@ -namespace KitX.Core.Workflow.CFG; +namespace KitX.Workflow.CFG; public class BlueprintDebugContext { diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/CFG/CFGBlock.cs b/KitX Clients/KitX Workflow/KitX.Workflow/CFG/CFGBlock.cs similarity index 98% rename from KitX Clients/KitX Core/KitX.Core/Workflow/CFG/CFGBlock.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/CFG/CFGBlock.cs index c530b7bf..b9e243f0 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/CFG/CFGBlock.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/CFG/CFGBlock.cs @@ -1,4 +1,4 @@ -namespace KitX.Core.Workflow.CFG; +namespace KitX.Workflow.CFG; /// /// Type of a CFG block, indicating its structural role in the control flow. diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/CFG/CFGEdge.cs b/KitX Clients/KitX Workflow/KitX.Workflow/CFG/CFGEdge.cs similarity index 98% rename from KitX Clients/KitX Core/KitX.Core/Workflow/CFG/CFGEdge.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/CFG/CFGEdge.cs index defa55e3..f3462421 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/CFG/CFGEdge.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/CFG/CFGEdge.cs @@ -1,4 +1,4 @@ -namespace KitX.Core.Workflow.CFG; +namespace KitX.Workflow.CFG; /// /// Type of a control flow edge between blocks. Making edge semantics diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/CFG/CFGStatement.cs b/KitX Clients/KitX Workflow/KitX.Workflow/CFG/CFGStatement.cs similarity index 99% rename from KitX Clients/KitX Core/KitX.Core/Workflow/CFG/CFGStatement.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/CFG/CFGStatement.cs index 249e1c79..989e20b4 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/CFG/CFGStatement.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/CFG/CFGStatement.cs @@ -1,4 +1,4 @@ -namespace KitX.Core.Workflow.CFG; +namespace KitX.Workflow.CFG; /// /// Kinds of statements in the CFG. Unified statement kind replacing the former Pipeline.FormattedStatementKind. diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/CFG/ConstDeclaration.cs b/KitX Clients/KitX Workflow/KitX.Workflow/CFG/ConstDeclaration.cs similarity index 97% rename from KitX Clients/KitX Core/KitX.Core/Workflow/CFG/ConstDeclaration.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/CFG/ConstDeclaration.cs index 374767d3..9e6446fa 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/CFG/ConstDeclaration.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/CFG/ConstDeclaration.cs @@ -1,4 +1,4 @@ -namespace KitX.Core.Workflow.CFG; +namespace KitX.Workflow.CFG; /// /// A constant declaration from #ConstBlock. Preserves both the raw source diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/CFG/ControlFlowGraph.cs b/KitX Clients/KitX Workflow/KitX.Workflow/CFG/ControlFlowGraph.cs similarity index 98% rename from KitX Clients/KitX Core/KitX.Core/Workflow/CFG/ControlFlowGraph.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/CFG/ControlFlowGraph.cs index 9e4ee88a..af96a512 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/CFG/ControlFlowGraph.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/CFG/ControlFlowGraph.cs @@ -1,7 +1,7 @@ -using KitX.Core.Contract.Workflow; +using KitX.Core.Contract.Workflow; using System.Text; -namespace KitX.Core.Workflow.CFG; +namespace KitX.Workflow.CFG; /// /// The Control Flow Graph — canonical intermediate representation for diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/ConstantValueRewriter.cs b/KitX Clients/KitX Workflow/KitX.Workflow/ConstantValueRewriter.cs similarity index 98% rename from KitX Clients/KitX Core/KitX.Core/Workflow/ConstantValueRewriter.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/ConstantValueRewriter.cs index 3757a612..fffa8730 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/ConstantValueRewriter.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/ConstantValueRewriter.cs @@ -1,8 +1,8 @@ -using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; -namespace KitX.Core.Workflow; +namespace KitX.Workflow; /// /// Rewriter for injecting constant values into variable declarations using CSharpSyntaxRewriter diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/IBlockScriptExecutor.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Contract/IBlockScriptExecutor.cs new file mode 100644 index 00000000..e1cb8925 --- /dev/null +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Contract/IBlockScriptExecutor.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using KitX.Core.Contract.Workflow; +using KitX.Workflow.Contract.Models; + +namespace KitX.Workflow.Contract; + +/// +/// Block script executor interface - executes parsed block scripts. +/// +/// Moved to the workflow library's internal Contract surface: its methods operate on +/// the parsed BlockScript model (also internal), and the Dashboard no longer +/// invokes executor methods directly (it routes execution through IBlockScriptService / +/// IBlueprintService, which take source strings). +/// +public interface IBlockScriptExecutor +{ + /// + /// Executes a block script + /// + /// The parsed block script + /// Input parameters + /// Cancellation token + /// Execution result + Task ExecuteAsync( + BlockScript script, + Dictionary? parameters = null, + CancellationToken cancellationToken = default); + + /// + /// Validates a block script + /// + BlockScriptValidationResult Validate(BlockScript script); + + /// + /// Sets an optional debug controller for interactive execution (breakpoints, step, slow). + /// Pass null to disable debug mode. + /// + void SetDebugger(IBlueprintDebugController? debugger); +} diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/IBlockScriptPipeline.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Contract/IBlockScriptPipeline.cs new file mode 100644 index 00000000..7bac2364 --- /dev/null +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Contract/IBlockScriptPipeline.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using KitX.Core.Contract.Workflow; + +namespace KitX.Workflow.Contract; + +/// +/// Block script parser interface - parses C# scripts with block attributes. +/// +/// Internal to the workflow pipeline (only BlockScriptExecutor / DI use it); +/// moved out of the public Contract surface. +/// +public interface IBlockScriptParser +{ + /// + /// Parses a block-based script from source code + /// + /// The C# source code with block attributes + /// Parsed block script result + BlockScriptParseResult Parse(string sourceCode); + + /// + /// Parses a block-based script from source code asynchronously + /// + Task ParseAsync(string sourceCode); + + /// + /// Validates block script syntax and structure + /// + BlockScriptValidationResult Validate(string sourceCode); +} + +/// +/// Block scope manager interface - manages variable scoping. +/// Internal to the workflow pipeline. +/// +public interface IBlockScopeManager +{ + /// + /// Gets the global (ConstBlock) scope + /// + IBlockScope GlobalScope { get; } + + /// + /// Resolves a variable name to its value (searches local then global) + /// + object? ResolveVariable(string name); + + /// + /// Sets a variable value in the appropriate scope + /// + void SetVariable(string name, object? value, bool global = false); + + /// + /// Checks if a variable exists in any scope + /// + bool HasVariable(string name); + + /// + /// Clears all local scopes (called between executions) + /// + void ClearLocalScopes(); +} + +/// +/// Variable scope interface. +/// Internal to the workflow pipeline. +/// +public interface IBlockScope +{ + /// + /// Name of the block this scope belongs to + /// + string BlockName { get; } + + /// + /// Whether this is the global scope + /// + bool IsGlobal { get; } + + /// + /// Gets a variable value + /// + object? GetVariable(string name); + + /// + /// Sets a variable value + /// + void SetVariable(string name, object? value); + + /// + /// Checks if a variable exists in this scope + /// + bool HasVariable(string name); + + /// + /// Gets all variables in this scope + /// + Dictionary GetAllVariables(); +} diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/IBlockScriptPipelineService.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Contract/IBlockScriptPipelineService.cs new file mode 100644 index 00000000..13d92353 --- /dev/null +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Contract/IBlockScriptPipelineService.cs @@ -0,0 +1,45 @@ +using System.Threading; +using System.Threading.Tasks; +using KitX.Core.Contract.Workflow; +using KitX.Workflow.Contract.Models; + +namespace KitX.Workflow.Contract; + +/// +/// Internal pipeline extension of . +/// +/// Carries the BlockScript methods that operate on the parsed BlockScript / +/// BlockScriptParseResult models — these are pipeline-internal and not consumed +/// by the Dashboard, so they are split out of the public Contract interface together +/// with the model types they reference (which also live in this namespace). +/// Implemented by the same BlockScriptServiceImpl that implements +/// IBlockScriptService. +/// +public interface IBlockScriptPipelineService +{ + /// + /// Parses a block script from source code. + /// + BlockScriptParseResult ParseBlockScript(string sourceCode); + + /// + /// Parses a block script from source code asynchronously. + /// + Task ParseBlockScriptAsync(string sourceCode); + + /// + /// Executes an already-parsed block script. + /// + Task ExecuteBlockScriptAsync( + BlockScript script, + System.Collections.Generic.Dictionary? parameters = null, + CancellationToken cancellationToken = default); + + /// + /// Compiles a parsed BlockScript and persists the compiled assembly to disk. + /// + /// The parsed BlockScript to compile. + /// The workflow ID for assembly naming. + /// True if compilation and persistence succeeded. + Task CompileAndPersistAsync(BlockScript script, string workflowId); +} diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/IBlueprintConverters.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Contract/IBlueprintConverters.cs new file mode 100644 index 00000000..9cea76cd --- /dev/null +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Contract/IBlueprintConverters.cs @@ -0,0 +1,46 @@ +using System.Collections.Generic; + +namespace KitX.Workflow.Contract; + +/// +/// Interface for converting BlockScript to Blueprint. +/// Internal to the workflow pipeline (consumed by BlueprintService). +/// +public interface IBlockScriptToBlueprintConverter +{ + /// + /// Convert BlockScript source code to Blueprint + /// + /// BlockScript source code + /// Helper functions available + /// Converted Blueprint + KitX.Core.Contract.Workflow.Blueprint Convert(string sourceCode, List? helperFunctions = null); + + /// + /// Convert parsed BlockScript to Blueprint + /// + /// Parsed BlockScript + /// Converted Blueprint + KitX.Core.Contract.Workflow.Blueprint Convert(KitX.Workflow.Contract.Models.BlockScript script); +} + +/// +/// Interface for converting Blueprint to BlockScript. +/// Internal to the workflow pipeline (consumed by BlueprintService). +/// +public interface IBlueprintToBlockScriptConverter +{ + /// + /// Convert Blueprint to BlockScript source code + /// + /// Blueprint to convert + /// BlockScript source code + string Convert(KitX.Core.Contract.Workflow.Blueprint blueprint); + + /// + /// Convert Blueprint to parsed BlockScript + /// + /// Blueprint to convert + /// Parsed BlockScript + KitX.Workflow.Contract.Models.BlockScript ConvertToBlockScript(KitX.Core.Contract.Workflow.Blueprint blueprint); +} diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/ILayoutService.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Contract/ILayoutService.cs new file mode 100644 index 00000000..18ac67dc --- /dev/null +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Contract/ILayoutService.cs @@ -0,0 +1,10 @@ +namespace KitX.Workflow.Contract; + +/// +/// Interface for layout service. +/// Internal to the workflow pipeline (consumed by BlockScriptToBlueprintConverter). +/// +public interface ILayoutService +{ + void LayoutNodes(KitX.Core.Contract.Workflow.Blueprint blueprint); +} diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/INodeExportStrategy.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Contract/INodeExportStrategy.cs new file mode 100644 index 00000000..f074311f --- /dev/null +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Contract/INodeExportStrategy.cs @@ -0,0 +1,89 @@ +using System.Collections.Generic; + +namespace KitX.Workflow.Contract; + +using ContractWorkflow = KitX.Core.Contract.Workflow; + +/// +/// Helper service providing data resolution utilities for node export strategies. +/// Implemented by BlueprintToBlockScriptConverter. +/// Internal to the workflow pipeline. +/// +public interface INodeExportHelper +{ + /// + /// Resolves the input value for a pin by tracing data connections. + /// Returns ConstName for const references, PubVarName for pubvar references, + /// or the pin's default value. + /// + string GetInputValue(ContractWorkflow.BlueprintNode node, string pinName); + + /// + /// Resolves all non-Exec input arguments for a node, returning them as a comma-separated string. + /// + string GetInputArgs(ContractWorkflow.BlueprintNode node); + + /// + /// The blueprint being converted. + /// + ContractWorkflow.Blueprint Blueprint { get; } + + /// + /// Returns the PubVar name assigned to the given output pin, or null if no data connection exists. + /// + string? GetOutputPubVar(ContractWorkflow.BlueprintNode node, string pinName); + + /// + /// Returns true if the given output pin is consumed by at least one data connection. + /// + bool IsOutputConsumed(ContractWorkflow.BlueprintNode node, string pinName); +} + +/// +/// Strategy for converting a specific node type to a BlockScript statement. +/// Each node type that participates in reverse conversion provides an implementation, +/// eliminating the need for switch-based dispatch in the converter. +/// Internal to the workflow pipeline. +/// +public interface INodeExportStrategy +{ + /// + /// The node type this strategy handles. + /// + ContractWorkflow.BlueprintNodeType NodeType { get; } + + /// + /// Whether this node type represents a control flow construct (Branch, Loop, etc.). + /// Used by the converter to determine main flow termination and sub-graph processing. + /// + bool IsControlFlow { get; } + + /// + /// Converts the node to a BlockScript statement, or null if the node should be skipped. + /// + KitX.Workflow.Contract.Models.BlockStatement? ToStatement(ContractWorkflow.BlueprintNode node, INodeExportHelper helper); + + /// + /// For control flow nodes: returns the output arm configuration. + /// Each arm defines an output pin name and whether it represents a loopback. + /// Non-control-flow strategies return an empty collection. + /// + IEnumerable GetOutputArms(ContractWorkflow.BlueprintNode node); +} + +/// +/// Describes a single output arm of a control flow node (e.g., Branch's True/False, Loop's LoopBody/LoopEnd). +/// Internal to the workflow pipeline. +/// +public struct OutputArmDescriptor +{ + /// + /// The output pin name (e.g., Pins.True, Pins.False, Pins.LoopBody, Pins.LoopEnd) + /// + public string PinName { get; set; } + + /// + /// Whether this arm loops back to a parent node (LoopBody loops back to the Loop node) + /// + public bool IsLoopback { get; set; } +} diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/IPluginManager.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Contract/IPluginManager.cs new file mode 100644 index 00000000..69fd9451 --- /dev/null +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Contract/IPluginManager.cs @@ -0,0 +1,41 @@ +using KitX.Core.Contract.Workflow; + +namespace KitX.Workflow.Contract; + +/// +/// 插件管理器接口 - BlockScripting 使用此接口调用插件方法。 +/// Copy of Kscript.CSharp.Parser.Core.IPluginManager for BlockScripting use +/// without depending on the KCS parser assembly. +/// 实际实现由 RealPluginManager 提供。 +/// +public interface IPluginManager +{ + /// + /// 调用插件方法 + /// + /// 返回值类型 + /// 调用信息 + /// 插件方法的返回值 + T Call(PluginCallInfo callInfo); + + /// + /// 调用插件方法(无返回值) + /// + /// 调用信息 + void Call(PluginCallInfo callInfo); + + /// + /// 检查插件是否存在 + /// + /// 插件名称 + /// 插件是否存在 + bool IsPluginExists(string pluginName); + + /// + /// 检查插件方法是否存在 + /// + /// 插件名称 + /// 方法名称 + /// 方法是否存在 + bool IsMethodExists(string pluginName, string methodName); +} diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/BlockDefinition.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/BlockDefinition.cs new file mode 100644 index 00000000..f29a04d5 --- /dev/null +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/BlockDefinition.cs @@ -0,0 +1,45 @@ +using System.Collections.Generic; + +namespace KitX.Workflow.Contract.Models; + +/// +/// Represents a single block definition in the script +/// +public class BlockDefinition +{ + /// + /// Block type + /// + public BlockType Type { get; set; } + + /// + /// Block name (for NamedBlock) + /// + public string Name { get; set; } = string.Empty; + + /// + /// Variable declarations in this block + /// + public List Variables { get; set; } = []; + + /// + /// Statements in this block (excluding Loop statements, which are separated) + /// + public List Statements { get; set; } = []; + + /// + /// Line number in source where this block starts + /// + public int LineNumber { get; set; } + + /// + /// Name of the next block to execute when this block ends naturally + /// (i.e., not ended by Branch/Loop/ToLoopCond) + /// + public string? NextBlockName { get; set; } + + /// + /// For LoopBlock: the block name containing this loop (i.e., the parent block) + /// + public string? ParentBlockName { get; set; } +} \ No newline at end of file diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/BlockScript.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/BlockScript.cs new file mode 100644 index 00000000..2ae2d2af --- /dev/null +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/BlockScript.cs @@ -0,0 +1,94 @@ +using System.Collections.Generic; + +using KitX.Core.Contract.Workflow; +namespace KitX.Workflow.Contract.Models; + +/// +/// Parsed block script container +/// +public class BlockScript +{ + /// + /// Global constants block (ConstBlock) + /// + public BlockDefinition? ConstBlock { get; set; } + + /// + /// Public variables block (PubVarBlock) - optional + /// + public BlockDefinition? PubVarBlock { get; set; } + + /// + /// Main entry block (MainBlock) + /// + public BlockDefinition? MainBlock { get; set; } + + /// + /// Named blocks dictionary by name + /// + public Dictionary NamedBlocks { get; set; } = []; + + /// + /// All blocks in order of appearance + /// + public List AllBlocks { get; set; } = []; + + /// + /// Loop blocks dictionary by parent block name + /// (e.g., "MainBlock" -> LoopBlock for MainBlock's Loop statement) + /// + public Dictionary LoopBlocks { get; set; } = []; + + /// + /// Raw source code (parsed input, may not include helper functions) + /// + public string SourceCode { get; set; } = string.Empty; + + /// + /// Full source code including merged helper functions (for execution) + /// + public string FullSourceCode { get; set; } = string.Empty; + + /// + /// Debug mapping from CFG statement IDs to Blueprint node IDs. + /// Populated during BP→BS conversion for use by the debug execution pipeline. + /// + public Dictionary? DebugNodeMapping { get; set; } + + /// + /// Helper functions to be made available in script execution context + /// + public List HelperFunctions { get; set; } = []; + + + /// + /// Gets a block by name (checks LoopBlocks first by block name, then NamedBlocks, then standard blocks) + /// IMPORTANT: LoopBlocks are checked FIRST because LoopBlock names (like "LoopBody") should NOT be + /// shadowed by user-defined NamedBlocks with the same name. + /// + public BlockDefinition? GetBlockByName(string name) + { + // First check LoopBlocks by the block's own name (not parent block name) + // This is critical because LoopBlocks are created for "NextBlock = Loop(...)" statements + // and their names (like "LoopBody") should take precedence over user-defined blocks + foreach (var kvp in LoopBlocks) + { + if (kvp.Value.Name == name) + return kvp.Value; + } + + // Then check NamedBlocks (user-defined blocks) + if (NamedBlocks.TryGetValue(name, out var namedBlock)) + return namedBlock; + + // Then check the standard blocks by name match + if (MainBlock?.Name == name) + return MainBlock; + if (ConstBlock?.Name == name) + return ConstBlock; + if (PubVarBlock?.Name == name) + return PubVarBlock; + + return null; + } +} \ No newline at end of file diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/BlockScriptModels.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/BlockScriptModels.cs new file mode 100644 index 00000000..551f7a34 --- /dev/null +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/BlockScriptModels.cs @@ -0,0 +1,32 @@ +namespace KitX.Workflow.Contract.Models; + +/// +/// Block type enumeration +/// +public enum BlockType +{ + /// + /// Constants block - variables are globally scoped and read-only + /// + ConstBlock, + + /// + /// Main block - entry point, local scope + /// + MainBlock, + + /// + /// Named block - local scope, can be called by name + /// + NamedBlock, + + /// + /// Public variable block - globally scoped and writable, but not exposed in UI editor + /// + PubVarBlock, + + /// + /// Loop block - auto-generated block containing a Loop statement + /// + LoopBlock +} \ No newline at end of file diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/FlowControlType.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/FlowControlType.cs new file mode 100644 index 00000000..88a6bc32 --- /dev/null +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/FlowControlType.cs @@ -0,0 +1,32 @@ +namespace KitX.Workflow.Contract.Models; + +/// +/// Flow control statement types +/// +public enum FlowControlType +{ + /// + /// Branch to another block based on condition + /// + Branch, + + /// + /// Loop while condition is true (Loop has three args: condition, trueBlock, falseBlock) + /// + Loop, + + /// + /// Return from script execution + /// + Return, + + /// + /// Break from current loop + /// + Break, + + /// + /// To loop condition - marks the end of a loop body and returns to loop condition + /// + ToLoopCond +} \ No newline at end of file diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/PluginCallInfo.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/PluginCallInfo.cs new file mode 100644 index 00000000..29e5427c --- /dev/null +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/PluginCallInfo.cs @@ -0,0 +1,59 @@ +using System; + +namespace KitX.Workflow.Contract.Models; + +/// +/// 插件调用信息,用于传递给 IPluginManager.Call 的参数。 +/// Copy of Kscript.CSharp.Parser.Models.PluginCallInfo for BlockScripting use +/// without depending on the KCS parser assembly. +/// +public class PluginCallInfo +{ + /// + /// 插件名称 + /// + public string PluginName { get; set; } = string.Empty; + + /// + /// 方法名称 + /// + public string MethodName { get; set; } = string.Empty; + + /// + /// 方法参数值数组 + /// + public object[] Parameters { get; set; } = Array.Empty(); + + /// + /// 参数类型数组 + /// + public Type[] ParameterTypes { get; set; } = Array.Empty(); + + /// + /// 参数名称数组 + /// + public string[] ParameterNames { get; set; } = Array.Empty(); + + /// + /// 目标设备名称(远程调用时使用)。如果为空或 null,则为本地调用。 + /// + public string? TargetDevice { get; set; } + + public PluginCallInfo() + { + } + + public PluginCallInfo(string pluginName, string methodName, object[] parameters, Type[] parameterTypes, string[] parameterNames) + { + PluginName = pluginName; + MethodName = methodName; + Parameters = parameters; + ParameterTypes = parameterTypes; + ParameterNames = parameterNames; + } + + public override string ToString() + { + return $"{PluginName}.{MethodName}({string.Join(", ", Parameters)})"; + } +} \ No newline at end of file diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/Results/BlockExecutionResult.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/Results/BlockExecutionResult.cs new file mode 100644 index 00000000..99885ddb --- /dev/null +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/Results/BlockExecutionResult.cs @@ -0,0 +1,48 @@ +namespace KitX.Workflow.Contract.Models; + +/// +/// Result of executing a block - used by state machine for flow control +/// +public class BlockExecutionResult +{ + /// + /// Whether execution should continue to next block + /// + public bool ShouldContinue { get; set; } = true; + + /// + /// Name of the next block to execute (null means end of script) + /// + public string? NextBlockName { get; set; } + + /// + /// Whether this is a return (end of entire script) + /// + public bool IsReturn { get; set; } + + /// + /// Return value if IsReturn is true + /// + public object? ReturnValue { get; set; } + + /// + /// Create a result for continuing to next block + /// + public static BlockExecutionResult ContinueTo(string? nextBlockName) => new() + { + ShouldContinue = true, + NextBlockName = nextBlockName, + IsReturn = false + }; + + /// + /// Create a result for end of script + /// + public static BlockExecutionResult Return(object? value = null) => new() + { + ShouldContinue = false, + NextBlockName = null, + IsReturn = true, + ReturnValue = value + }; +} \ No newline at end of file diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/Results/BlockScriptParseResult.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/Results/BlockScriptParseResult.cs new file mode 100644 index 00000000..de063295 --- /dev/null +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/Results/BlockScriptParseResult.cs @@ -0,0 +1,27 @@ +namespace KitX.Workflow.Contract.Models; + +/// +/// Result of parsing operation +/// +public class BlockScriptParseResult +{ + /// + /// Whether parsing was successful + /// + public bool IsSuccess { get; set; } + + /// + /// Error message if parsing failed + /// + public string? ErrorMessage { get; set; } + + /// + /// Line number where error occurred + /// + public int ErrorLine { get; set; } + + /// + /// The parsed script if successful + /// + public BlockScript? Script { get; set; } +} \ No newline at end of file diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/Statements/BlockStatement.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/Statements/BlockStatement.cs new file mode 100644 index 00000000..8eed3ccc --- /dev/null +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/Statements/BlockStatement.cs @@ -0,0 +1,24 @@ +namespace KitX.Workflow.Contract.Models; + +/// +/// Base class for statements within a block +/// +public abstract class BlockStatement +{ + /// + /// Debug statement ID — preserved through BP⇄BS round-trip. + /// Set by CFG→BS conversion; consumed by BS→CFG conversion. + /// Empty means "unset; generate a new ID". + /// + public string StatementId { get; set; } = string.Empty; + + /// + /// Line number in source + /// + public int LineNumber { get; set; } + + /// + /// Original source code for this statement + /// + public string SourceCode { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/Statements/ExpressionStatement.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/Statements/ExpressionStatement.cs new file mode 100644 index 00000000..b2356df4 --- /dev/null +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/Statements/ExpressionStatement.cs @@ -0,0 +1,12 @@ +namespace KitX.Workflow.Contract.Models; + +/// +/// Expression statement +/// +public class ExpressionStatement : BlockStatement +{ + /// + /// The expression to execute + /// + public string Expression { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/Statements/FlowControlStatement.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/Statements/FlowControlStatement.cs new file mode 100644 index 00000000..45d6e354 --- /dev/null +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/Statements/FlowControlStatement.cs @@ -0,0 +1,51 @@ +namespace KitX.Workflow.Contract.Models; + +/// +/// Flow control statement +/// +public class FlowControlStatement : BlockStatement +{ + /// + /// Type of flow control + /// + public FlowControlType ControlType { get; set; } + + /// + /// Condition expression (for Branch/Loop) + /// + public string ConditionExpression { get; set; } = string.Empty; + + /// + /// Target block name when condition is true (for Branch/Loop) + /// + public string TrueBlockName { get; set; } = string.Empty; + + /// + /// Target block name when condition is false (for Branch/Loop) + /// For Loop: this is the loop exit block + /// + public string FalseBlockName { get; set; } = string.Empty; + + /// + /// For ToLoopCond: the block name containing the Loop statement to return to + /// + public string? ToLoopCondReturnTo { get; set; } + + /// + /// Regenerates SourceCode from current field values. + /// Call after updating TrueBlockName/FalseBlockName/etc. to keep SourceCode in sync. + /// + public void RegenerateSourceCode() + { + SourceCode = ControlType switch + { + FlowControlType.Branch => $"NextBlock = Branch({ConditionExpression}, \"{TrueBlockName}\", \"{FalseBlockName}\");", + FlowControlType.Loop => $"NextBlock = Loop({ConditionExpression}, \"{TrueBlockName}\", \"{FalseBlockName}\");", + FlowControlType.ToLoopCond => ToLoopCondReturnTo != null + ? $"NextBlock = ToLoopCond(\"{ToLoopCondReturnTo}\");" + : "ToLoopCond();", + FlowControlType.Break => "Break();", + _ => SourceCode + }; + } +} \ No newline at end of file diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/Statements/VariableDeclarationStatement.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/Statements/VariableDeclarationStatement.cs new file mode 100644 index 00000000..16ac10b7 --- /dev/null +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/Statements/VariableDeclarationStatement.cs @@ -0,0 +1,12 @@ +namespace KitX.Workflow.Contract.Models; + +/// +/// Variable declaration statement +/// +public class VariableDeclarationStatement : BlockStatement +{ + /// + /// The variable declaration + /// + public VariableDeclaration Declaration { get; set; } = new(); +} \ No newline at end of file diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/VariableDeclaration.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/VariableDeclaration.cs new file mode 100644 index 00000000..99368a34 --- /dev/null +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/VariableDeclaration.cs @@ -0,0 +1,27 @@ +namespace KitX.Workflow.Contract.Models; + +/// +/// Variable declaration +/// +public class VariableDeclaration +{ + /// + /// Variable name + /// + public string Name { get; set; } = string.Empty; + + /// + /// Variable type as string + /// + public string Type { get; set; } = "object"; + + /// + /// Initial value expression as string (for evaluation at parse time or execution time) + /// + public string? InitialValueExpression { get; set; } + + /// + /// Default value (pre-evaluated for const block) + /// + public object? DefaultValue { get; set; } +} \ No newline at end of file diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/BP2CFGConverter.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BP2CFGConverter.cs similarity index 96% rename from KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/BP2CFGConverter.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BP2CFGConverter.cs index 93f0ef9d..141bf1a7 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/BP2CFGConverter.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BP2CFGConverter.cs @@ -1,20 +1,20 @@ -using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.Blueprint; +using KitX.Core.Contract.Workflow; +using KitX.Workflow.Blueprint; using Microsoft.CodeAnalysis.CSharp.Syntax; using Serilog; -using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Pins; -using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Blocks; +using static KitX.Workflow.BlockScripting.BlockScriptWellKnown.Pins; +using static KitX.Workflow.BlockScripting.BlockScriptWellKnown.Blocks; -using KitX.Core.Workflow.CFG; -using KitX.Core.Workflow.BlockScripting; -namespace KitX.Core.Workflow.Conversion; +using KitX.Workflow.CFG; +using KitX.Workflow.BlockScripting; +namespace KitX.Workflow.Conversion; /// /// Builds a from a . /// This is the unified BP→BS algorithm. /// -/// Whether is present or not, +/// Whether is present or not, /// this single algorithm produces a CFG. When BlockScopes are available, they guide /// block membership; when absent, blocks are derived from topology alone. /// @@ -38,7 +38,7 @@ public BP2CFGConverter( /// Builds a ControlFlowGraph from a Blueprint. /// Single entry point that unifies the BlockScopes-based and topology-based paths. /// - public ControlFlowGraph Build(Contract.Workflow.Blueprint blueprint) + public ControlFlowGraph Build(KitX.Core.Contract.Workflow.Blueprint blueprint) { var cfg = new ControlFlowGraph { @@ -149,7 +149,7 @@ public ControlFlowGraph Build(Contract.Workflow.Blueprint blueprint) // ════════════════════════════════════════════════════════════════════ private void AnalyzeConnections( - Contract.Workflow.Blueprint blueprint, + KitX.Core.Contract.Workflow.Blueprint blueprint, Dictionary nodeById, List execConns, List dataConns, @@ -249,7 +249,7 @@ or BlueprintNodeType.CallHelper // ════════════════════════════════════════════════════════════════════ private static HashSet FindReachableNodeIds( - Contract.Workflow.Blueprint blueprint, + KitX.Core.Contract.Workflow.Blueprint blueprint, Dictionary nodeById, List execConns) { @@ -285,7 +285,7 @@ private static HashSet FindReachableNodeIds( // ════════════════════════════════════════════════════════════════════ private void BuildBlocksFromScopes( - Contract.Workflow.Blueprint blueprint, + KitX.Core.Contract.Workflow.Blueprint blueprint, ControlFlowGraph cfg, Dictionary nodeById, HashSet reachableNodeIds, @@ -319,7 +319,7 @@ private void BuildBlocksFromScopes( // ════════════════════════════════════════════════════════════════════ private void BuildBlocksFromTopology( - Contract.Workflow.Blueprint blueprint, + KitX.Core.Contract.Workflow.Blueprint blueprint, ControlFlowGraph cfg, Dictionary nodeById, HashSet reachableNodeIds, @@ -351,7 +351,7 @@ private void BuildBlocksFromTopology( private void WalkNode( BlueprintNode node, CFGBlock currentBlock, ControlFlowGraph cfg, Dictionary nodeById, HashSet reachableNodeIds, - List execConns, Contract.Workflow.Blueprint blueprint, + List execConns, KitX.Core.Contract.Workflow.Blueprint blueprint, HashSet visited, List pendingControlFlowNodes, Dictionary loopNodes, Dictionary loopOwnerBlockNames, @@ -432,7 +432,7 @@ private void WalkNode( private void ProcessSubGraphs( ControlFlowGraph cfg, Dictionary nodeById, HashSet reachableNodeIds, List execConns, - Contract.Workflow.Blueprint blueprint, + KitX.Core.Contract.Workflow.Blueprint blueprint, List pendingControlFlowNodes, Dictionary loopNodes, Dictionary loopOwnerBlockNames, @@ -459,7 +459,7 @@ private void ProcessSubGraphs( private void ProcessControlFlowSubGraph( BlueprintNode cfNode, ControlFlowGraph cfg, Dictionary nodeById, HashSet reachableNodeIds, - List execConns, Contract.Workflow.Blueprint blueprint, + List execConns, KitX.Core.Contract.Workflow.Blueprint blueprint, Dictionary loopNodes, Dictionary loopOwnerBlockNames, ref int blockCounter) { @@ -529,7 +529,7 @@ private void ProcessControlFlowSubGraph( // ════════════════════════════════════════════════════════════════════ private void BuildEdgesFromTopology( - ControlFlowGraph cfg, Contract.Workflow.Blueprint blueprint, + ControlFlowGraph cfg, KitX.Core.Contract.Workflow.Blueprint blueprint, Dictionary nodeById, List execConns) { foreach (var block in cfg.Blocks) @@ -635,7 +635,7 @@ private void BuildEdgesFromTopology( /// This replaces the ad-hoc NextBlockName fallback logic. /// private static void ResolveMissingNextBlockNames( - ControlFlowGraph cfg, Contract.Workflow.Blueprint blueprint, + ControlFlowGraph cfg, KitX.Core.Contract.Workflow.Blueprint blueprint, Dictionary nodeById, List execConns) { foreach (var block in cfg.Blocks) @@ -754,7 +754,7 @@ private static void SetParentLoopReferences(ControlFlowGraph cfg) private CFGStatement? GenerateStatement( BlueprintNode node, Dictionary nodeById, - List execConns, Contract.Workflow.Blueprint blueprint) + List execConns, KitX.Core.Contract.Workflow.Blueprint blueprint) { // Delegate to existing strategy-based generation, then convert to CFGStatement var blockStmt = GenerateBlockStatement(node, nodeById, execConns, blueprint); @@ -769,7 +769,7 @@ private static void SetParentLoopReferences(ControlFlowGraph cfg) /// private BlockStatement? GenerateBlockStatement( BlueprintNode node, Dictionary nodeById, - List execConns, Contract.Workflow.Blueprint blueprint) + List execConns, KitX.Core.Contract.Workflow.Blueprint blueprint) { switch (node.NodeType) { @@ -872,7 +872,7 @@ private void PostProcessCallReturn(BlueprintNode node, BlockStatement stmt) /// Sets the context for PubVar resolution during statement generation. /// Called before Build() when BlockScopes path is used. /// - public void SetContext(Contract.Workflow.Blueprint blueprint, ConversionContext? ctx) + public void SetContext(KitX.Core.Contract.Workflow.Blueprint blueprint, ConversionContext? ctx) { _exportHelper.SetContext(blueprint, ctx); _currentCtx = ctx; @@ -999,7 +999,7 @@ private static CFGStatement CreateToLoopCondStatement(string? returnTo) /// private static void ResolveControlFlowTargets( ControlFlowGraph cfg, - Contract.Workflow.Blueprint blueprint, + KitX.Core.Contract.Workflow.Blueprint blueprint, Dictionary nodeById, List execConns) { @@ -1022,7 +1022,7 @@ private static void ResolveControlFlowTargets( private static void ResolveControlFlowTargetsForNode( CFGStatement stmt, BlueprintNode cfNode, - ControlFlowGraph cfg, Contract.Workflow.Blueprint blueprint, + ControlFlowGraph cfg, KitX.Core.Contract.Workflow.Blueprint blueprint, List execConns) { foreach (var pin in cfNode.OutputPins.Where(p => p.Type == PinType.Execution)) @@ -1072,7 +1072,7 @@ private static void ResolveControlFlowTargetsForNode( } private static string? FindBlockContainingNode(ControlFlowGraph cfg, string targetNodeId, - Contract.Workflow.Blueprint blueprint) + KitX.Core.Contract.Workflow.Blueprint blueprint) { // Find which BlockScope contains the target node foreach (var scope in blueprint.BlockScopes) diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/BS2CFGConverter.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BS2CFGConverter.cs similarity index 98% rename from KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/BS2CFGConverter.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BS2CFGConverter.cs index bbac769e..ed48fe03 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/BS2CFGConverter.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BS2CFGConverter.cs @@ -1,13 +1,13 @@ -using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.BlockScripting; +using KitX.Workflow.BlockScripting; using Serilog; -using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Functions; -using KitX.Core.Workflow.CFG; +using static KitX.Workflow.BlockScripting.BlockScriptWellKnown.Functions; +using KitX.Workflow.CFG; -using KitX.Core.Workflow.Blueprint; -namespace KitX.Core.Workflow.Conversion; +using KitX.Workflow.Blueprint; +namespace KitX.Workflow.Conversion; /// /// Phase 2: Takes a parsed BlockScript AST and produces a ControlFlowGraph diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/BlockScriptSerializer.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BlockScriptSerializer.cs similarity index 94% rename from KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/BlockScriptSerializer.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BlockScriptSerializer.cs index 2a35abf0..416be0c9 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/BlockScriptSerializer.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BlockScriptSerializer.cs @@ -1,11 +1,11 @@ -using System.Text; +using System.Text; using KitX.Core.Contract.Workflow; -using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Blocks; +using static KitX.Workflow.BlockScripting.BlockScriptWellKnown.Blocks; -using KitX.Core.Workflow.CFG; -using KitX.Core.Workflow.BlockScripting; -using KitX.Core.Workflow.Blueprint; -namespace KitX.Core.Workflow.Conversion; +using KitX.Workflow.CFG; +using KitX.Workflow.BlockScripting; +using KitX.Workflow.Blueprint; +namespace KitX.Workflow.Conversion; /// /// Serializes a to source code string. diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/BlockScriptToBlueprintConverter.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BlockScriptToBlueprintConverter.cs similarity index 95% rename from KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/BlockScriptToBlueprintConverter.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BlockScriptToBlueprintConverter.cs index 5961d865..cfe4adb7 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/BlockScriptToBlueprintConverter.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BlockScriptToBlueprintConverter.cs @@ -1,10 +1,10 @@ -using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.BlockScripting; -using KitX.Core.Workflow.CFG; +using KitX.Core.Contract.Workflow; +using KitX.Workflow.BlockScripting; +using KitX.Workflow.CFG; using Serilog; -using KitX.Core.Workflow.Blueprint; -namespace KitX.Core.Workflow.Conversion; +using KitX.Workflow.Blueprint; +namespace KitX.Workflow.Conversion; /// /// Converts BlockScript to Blueprint via a clean 6-phase pipeline. @@ -41,7 +41,7 @@ public BlockScriptToBlueprintConverter( _functionRegistry = functionRegistry; } - public Contract.Workflow.Blueprint Convert(string sourceCode, List? helperFunctions = null) + public KitX.Core.Contract.Workflow.Blueprint Convert(string sourceCode, List? helperFunctions = null) { var result = _parser.Parse(sourceCode); if (!result.IsSuccess || result.Script == null) @@ -53,7 +53,7 @@ public Contract.Workflow.Blueprint Convert(string sourceCode, List(); diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/BlueprintToBlockScriptConverter.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BlueprintToBlockScriptConverter.cs similarity index 87% rename from KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/BlueprintToBlockScriptConverter.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BlueprintToBlockScriptConverter.cs index aa92c037..eda83141 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/BlueprintToBlockScriptConverter.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BlueprintToBlockScriptConverter.cs @@ -1,10 +1,10 @@ -using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.CFG; +using KitX.Core.Contract.Workflow; +using KitX.Workflow.CFG; using Serilog; -using KitX.Core.Workflow.BlockScripting; -using KitX.Core.Workflow.Blueprint; -namespace KitX.Core.Workflow.Conversion; +using KitX.Workflow.BlockScripting; +using KitX.Workflow.Blueprint; +namespace KitX.Workflow.Conversion; /// /// Converts Blueprint back to a fully-expanded BlockScript source code. @@ -44,12 +44,12 @@ public BlueprintToBlockScriptConverter(IEnumerable strategi internal ControlFlowGraph? LastCFG { get; private set; } - public Contract.Workflow.Blueprint Blueprint { get; private set; } = null!; + public KitX.Core.Contract.Workflow.Blueprint Blueprint { get; private set; } = null!; - public string Convert(Contract.Workflow.Blueprint blueprint) + public string Convert(KitX.Core.Contract.Workflow.Blueprint blueprint) => ConvertToBlockScript(blueprint).SourceCode; - public BlockScript ConvertToBlockScript(Contract.Workflow.Blueprint blueprint) + public BlockScript ConvertToBlockScript(KitX.Core.Contract.Workflow.Blueprint blueprint) { Blueprint = blueprint; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/BuiltinFunctionExportStrategyAdapter.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BuiltinFunctionExportStrategyAdapter.cs similarity index 88% rename from KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/BuiltinFunctionExportStrategyAdapter.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BuiltinFunctionExportStrategyAdapter.cs index efdc5f42..78f3aaa4 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/BuiltinFunctionExportStrategyAdapter.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BuiltinFunctionExportStrategyAdapter.cs @@ -1,9 +1,9 @@ -using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.BlockScripting; -using KitX.Core.Workflow.CFG; +using KitX.Core.Contract.Workflow; +using KitX.Workflow.BlockScripting; +using KitX.Workflow.CFG; -using KitX.Core.Workflow.Blueprint; -namespace KitX.Core.Workflow.Conversion; +using KitX.Workflow.Blueprint; +namespace KitX.Workflow.Conversion; /// /// 将 适配为 , diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/CFG2BPConverter.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFG2BPConverter.cs similarity index 98% rename from KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/CFG2BPConverter.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFG2BPConverter.cs index 2a4285c9..b7dc62fb 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/CFG2BPConverter.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFG2BPConverter.cs @@ -1,12 +1,12 @@ -using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.BlockScripting; +using KitX.Core.Contract.Workflow; +using KitX.Workflow.BlockScripting; using Serilog; -using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Pins; -using KitX.Core.Workflow.CFG; +using static KitX.Workflow.BlockScripting.BlockScriptWellKnown.Pins; +using KitX.Workflow.CFG; -using KitX.Core.Workflow.Blueprint; -namespace KitX.Core.Workflow.Conversion; +using KitX.Workflow.Blueprint; +namespace KitX.Workflow.Conversion; /// /// Phase 3: Creates all Blueprint nodes and exec flow edges from ControlFlowGraph. diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/CFG2BSConverter.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFG2BSConverter.cs similarity index 95% rename from KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/CFG2BSConverter.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFG2BSConverter.cs index 01f7dbae..25457039 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/CFG2BSConverter.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFG2BSConverter.cs @@ -1,10 +1,10 @@ -using KitX.Core.Contract.Workflow; -using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Blocks; +using KitX.Core.Contract.Workflow; +using static KitX.Workflow.BlockScripting.BlockScriptWellKnown.Blocks; -using KitX.Core.Workflow.CFG; -using KitX.Core.Workflow.BlockScripting; -using KitX.Core.Workflow.Blueprint; -namespace KitX.Core.Workflow.Conversion; +using KitX.Workflow.CFG; +using KitX.Workflow.BlockScripting; +using KitX.Workflow.Blueprint; +namespace KitX.Workflow.Conversion; /// /// Generates a from a . diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/CFG2CSGenerator.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFG2CSGenerator.cs similarity index 98% rename from KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/CFG2CSGenerator.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFG2CSGenerator.cs index 49dfd3a2..baf6a16a 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/CFG2CSGenerator.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFG2CSGenerator.cs @@ -1,15 +1,15 @@ -using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; using Serilog; using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; -using KitX.Core.Workflow.CFG; +using KitX.Workflow.CFG; -using KitX.Core.Workflow.BlockScripting; -using KitX.Core.Workflow.Blueprint; -namespace KitX.Core.Workflow.Conversion; +using KitX.Workflow.BlockScripting; +using KitX.Workflow.Blueprint; +namespace KitX.Workflow.Conversion; /// /// Generates Roslyn from a . @@ -112,7 +112,7 @@ internal static Dictionary InferPubVarTypes( /// Generates the complete for the compiled script. /// Structure: /// - /// namespace KitX.Core.Workflow.BlockScripting.Generated { + /// namespace KitX.Workflow.BlockScripting.Generated { /// public class CompiledScript_<hash> : ICompiledBlockScript { /// public static T ConvertTo<T>(object? value) { ... } /// // Helper functions as static methods (typed, NO wrappers) @@ -145,7 +145,7 @@ internal static CompilationUnitSyntax GenerateCompilationUnit( classDecl = classDecl.AddMembers(runMethod); var nsDecl = NamespaceDeclaration( - ParseName("KitX.Core.Workflow.BlockScripting.Generated")) + ParseName("KitX.Workflow.BlockScripting.Generated")) .AddMembers(classDecl); var usings = new List @@ -154,7 +154,7 @@ internal static CompilationUnitSyntax GenerateCompilationUnit( UsingDirective(ParseName("System.Threading")), UsingDirective(ParseName("System.Threading.Tasks")), UsingDirective(ParseName("KitX.Core.Contract.Workflow")), - UsingDirective(ParseName("KitX.Core.Workflow.BlockScripting")) + UsingDirective(ParseName("KitX.Workflow.BlockScripting")) }; return CompilationUnit() diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/CFGConditionDuplicator.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFGConditionDuplicator.cs similarity index 98% rename from KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/CFGConditionDuplicator.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFGConditionDuplicator.cs index 6e96a4b7..9bc545e7 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/CFGConditionDuplicator.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFGConditionDuplicator.cs @@ -1,4 +1,4 @@ -namespace KitX.Core.Workflow.CFG; +namespace KitX.Workflow.CFG; /// /// Duplicates Loop condition evaluation statements before each ToLoopCond diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/CFGPipeline.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFGPipeline.cs similarity index 93% rename from KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/CFGPipeline.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFGPipeline.cs index d95660f3..f71c2d8e 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/CFGPipeline.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFGPipeline.cs @@ -1,9 +1,9 @@ -using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.BlockScripting; -using KitX.Core.Workflow.Blueprint; +using KitX.Core.Contract.Workflow; +using KitX.Workflow.BlockScripting; +using KitX.Workflow.Blueprint; -using KitX.Core.Workflow.CFG; -namespace KitX.Core.Workflow.Conversion; +using KitX.Workflow.CFG; +namespace KitX.Workflow.Conversion; /// /// Canonical CFG pipeline: five sub-path functions that compose into four main paths. @@ -53,7 +53,7 @@ internal static ControlFlowGraph BS2CFG( /// Equivalent to BP2CFGConverter.Build(). /// internal static ControlFlowGraph BP2CFG( - Contract.Workflow.Blueprint blueprint, + KitX.Core.Contract.Workflow.Blueprint blueprint, Dictionary strategyMap, Dictionary builtinMap, NodeExportHelper exportHelper, diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/ConversionContext.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/ConversionContext.cs similarity index 89% rename from KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/ConversionContext.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Conversion/ConversionContext.cs index 5f50b107..6a5229ad 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/ConversionContext.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/ConversionContext.cs @@ -1,9 +1,9 @@ -using KitX.Core.Contract.Workflow; +using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.CFG; -using KitX.Core.Workflow.BlockScripting; -using KitX.Core.Workflow.Blueprint; -namespace KitX.Core.Workflow.Conversion; +using KitX.Workflow.CFG; +using KitX.Workflow.BlockScripting; +using KitX.Workflow.Blueprint; +namespace KitX.Workflow.Conversion; /// /// Accumulated state for Blueprint → BlockScript conversion. @@ -11,7 +11,7 @@ namespace KitX.Core.Workflow.Conversion; /// internal class ConversionContext { - public required Contract.Workflow.Blueprint Blueprint { get; set; } + public required KitX.Core.Contract.Workflow.Blueprint Blueprint { get; set; } public required BlockScript Script { get; set; } // Phase 1: Connection analysis diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/DataEdgeBuilder.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/DataEdgeBuilder.cs similarity index 97% rename from KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/DataEdgeBuilder.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Conversion/DataEdgeBuilder.cs index dd4bc999..1c779fa1 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/DataEdgeBuilder.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/DataEdgeBuilder.cs @@ -1,12 +1,12 @@ -using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.BlockScripting; +using KitX.Core.Contract.Workflow; +using KitX.Workflow.BlockScripting; -using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Pins; +using static KitX.Workflow.BlockScripting.BlockScriptWellKnown.Pins; using Serilog; -using KitX.Core.Workflow.CFG; +using KitX.Workflow.CFG; -using KitX.Core.Workflow.Blueprint; -namespace KitX.Core.Workflow.Conversion; +using KitX.Workflow.Blueprint; +namespace KitX.Workflow.Conversion; /// /// Phase 4+5: Creates data edges from ControlFlowGraph argument analysis. diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/ExprUtils.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/ExprUtils.cs similarity index 97% rename from KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/ExprUtils.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Conversion/ExprUtils.cs index 5598b07d..a8679f4f 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/ExprUtils.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/ExprUtils.cs @@ -1,11 +1,11 @@ -using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; -using KitX.Core.Workflow.CFG; -using KitX.Core.Workflow.BlockScripting; -using KitX.Core.Workflow.Blueprint; -namespace KitX.Core.Workflow.Conversion; +using KitX.Workflow.CFG; +using KitX.Workflow.BlockScripting; +using KitX.Workflow.Blueprint; +namespace KitX.Workflow.Conversion; /// /// Shared expression-parsing utilities used across pipeline phases. diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/PipelineAssembler.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/PipelineAssembler.cs similarity index 95% rename from KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/PipelineAssembler.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Conversion/PipelineAssembler.cs index cde52f1c..e691ea41 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/PipelineAssembler.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/PipelineAssembler.cs @@ -1,10 +1,10 @@ -using KitX.Core.Contract.Workflow; +using KitX.Core.Contract.Workflow; using Serilog; -using KitX.Core.Workflow.CFG; -using KitX.Core.Workflow.BlockScripting; -using KitX.Core.Workflow.Blueprint; -namespace KitX.Core.Workflow.Conversion; +using KitX.Workflow.CFG; +using KitX.Workflow.BlockScripting; +using KitX.Workflow.Blueprint; +namespace KitX.Workflow.Conversion; /// /// Phase 6: Assembles the final Blueprint from all pipeline outputs. @@ -15,11 +15,11 @@ public class PipelineAssembler private PipelineContext _ctx = null!; private Dictionary _nodeById = new(); - public Contract.Workflow.Blueprint Assemble(PipelineContext context) + public KitX.Core.Contract.Workflow.Blueprint Assemble(PipelineContext context) { _ctx = context; - var bp = new Contract.Workflow.Blueprint + var bp = new KitX.Core.Contract.Workflow.Blueprint { Name = "Imported from BlockScript", PubVarNames = new List(context.PubVarNames), @@ -172,7 +172,7 @@ public Contract.Workflow.Blueprint Assemble(PipelineContext context) // Block Scope Construction // ────────────────────────────────────────────── - private void BuildBlockScopes(Contract.Workflow.Blueprint bp, PipelineContext ctx) + private void BuildBlockScopes(KitX.Core.Contract.Workflow.Blueprint bp, PipelineContext ctx) { var mainBlockName = ctx.FormattedScript.MainBlockName; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/PipelineContext.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/PipelineContext.cs similarity index 96% rename from KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/PipelineContext.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Conversion/PipelineContext.cs index b9b83e69..27f9ba9c 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/Conversion/PipelineContext.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/PipelineContext.cs @@ -1,10 +1,10 @@ -using static KitX.Core.Workflow.BlockScripting.BlockScriptWellKnown.Pins; +using static KitX.Workflow.BlockScripting.BlockScriptWellKnown.Pins; using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.CFG; +using KitX.Workflow.CFG; -using KitX.Core.Workflow.BlockScripting; -using KitX.Core.Workflow.Blueprint; -namespace KitX.Core.Workflow.Conversion; +using KitX.Workflow.BlockScripting; +using KitX.Workflow.Blueprint; +namespace KitX.Workflow.Conversion; /// /// Accumulated state flowing through all 6 conversion phases. diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/GlobalUsings.cs b/KitX Clients/KitX Workflow/KitX.Workflow/GlobalUsings.cs new file mode 100644 index 00000000..0aab36f1 --- /dev/null +++ b/KitX Clients/KitX Workflow/KitX.Workflow/GlobalUsings.cs @@ -0,0 +1,8 @@ +// Global usings for the KitX.Workflow library. +// These are shared across all source files so that the migrated code (which originally +// relied on file-level usings under KitX.Core.Workflow) resolves common namespaces +// without each file having to repeat them. + +global using KitX.Workflow.Hosting; +global using KitX.Workflow.Contract; +global using KitX.Workflow.Contract.Models; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Hosting/ServiceCollectionExtensions.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Hosting/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..da82243c --- /dev/null +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Hosting/ServiceCollectionExtensions.cs @@ -0,0 +1,135 @@ +using Microsoft.Extensions.DependencyInjection; +using KitX.Core.Contract.Workflow; +using KitX.Core.Contract.Plugin; +using KitX.Core.Contract.Device; +using KitX.Core.Contract.Event; +using KitX.Workflow; +using KitX.Workflow.BlockScripting; +using KitX.Workflow.Blueprint; +using KitX.Workflow.Conversion; +using Serilog; + +namespace KitX.Workflow.Hosting; + +/// +/// DI registration extensions for the KitX.Workflow library. +/// Host applications (KitX.Core) call to register +/// the full workflow service graph: BlockScript parsing/execution, blueprint +/// conversion pipeline, builtin-function registry, and the plugin/trigger bridges. +/// +public static class ServiceCollectionExtensions +{ + /// + /// Registers all KitX.Workflow services in the dependency injection container. + /// The host must have already registered the shared Core services that workflow + /// depends on (IPluginServer, IDeviceServer, IEventService, IDeviceHttpClient, + /// IDeviceDiscoveryService) before calling this. + /// + /// The service collection to add services to. + /// The service collection for chaining. + public static IServiceCollection AddKitXWorkflow(this IServiceCollection services) + { + Log.Information("[AddKitXWorkflow] Registering workflow services..."); + + // IBlockScriptService is created via factory to inject RealPluginManager from DI. + // WorkflowScriptService facade is kept as the backward-compat singleton graph. + services.AddSingleton(provider => + { + var state = WorkflowScriptService.RuntimeState; + var rpm = provider.GetRequiredService(); + var service = new BlockScriptServiceImpl(state, rpm); + Log.Information("[DI] IBlockScriptService created with RealPluginManager. HashCode: {HashCode}", rpm.GetHashCode()); + return service; + }); + // IBlockScriptPipelineService shares the same BlockScriptServiceImpl instance + // (it carries the parsed-model methods split out of the public IBlockScriptService). + services.AddSingleton(sp => (IBlockScriptPipelineService)sp.GetRequiredService()); + services.AddSingleton(sp => WorkflowScriptService.PluginServiceInstance); + services.AddSingleton(sp => WorkflowScriptService.ManagementServiceInstance); + + // RealPluginManager — singleton so PluginsServer and WorkflowScriptService share + // the same instance, ensuring plugin connection events are properly received. + services.AddSingleton(provider => + { + var pluginServer = provider.GetRequiredService(); + var eventService = provider.GetRequiredService(); + var deviceDiscoveryService = provider.GetRequiredService(); + var deviceServer = provider.GetRequiredService(); + return new RealPluginManager(pluginServer, eventService, deviceDiscoveryService, deviceServer, provider.GetRequiredService()); + }); + + // KCS File Service + services.AddSingleton(); + + // Block Script Services + services.AddSingleton(provider => + { + var funcRegistry = provider.GetService(); + return new BlockScriptParser(funcRegistry); + }); + + services.AddSingleton(provider => + { + var service = new BlockScriptExecutor(); + try + { + var pluginManager = provider.GetRequiredService(); + service.SetPluginManager(pluginManager); + Log.Information("[DI] IBlockScriptExecutor: RealPluginManager HashCode = {HashCode}", pluginManager.GetHashCode()); + } + catch (Exception ex) + { + Log.Error(ex, "[DI] Could not initialize BlockScriptExecutor with RealPluginManager from DI container"); + } + return service; + }); + + services.AddSingleton(); + + // Blueprint sub-services (must be registered before IBlueprintService) + // Discover and register all IBuiltinFunctionDefinition implementations + var functionRegistry = BuiltinFunctionRegistry.Discover(typeof(BuiltinFunctionRegistry).Assembly); + services.AddSingleton(functionRegistry); + + // NodeRegistry with function registry for dynamic node creation + services.AddSingleton(provider => + { + var reg = provider.GetRequiredService(); + return new NodeRegistry(reg); + }); + + services.AddSingleton(); + services.AddSingleton(); + + // Blueprint Converters + services.AddSingleton(provider => + { + var parser = provider.GetRequiredService(); + var nodeRegistry = provider.GetRequiredService(); + var layoutService = provider.GetRequiredService(); + var funcRegistry = provider.GetService(); + return new BlockScriptToBlueprintConverter(parser, nodeRegistry, layoutService, funcRegistry!); + }); + services.AddSingleton(); + + // Blueprint Export Strategies — all auto-registered from IBuiltinFunctionDefinition implementations + foreach (var def in functionRegistry.AllDefinitions) + { + services.AddSingleton(new BuiltinFunctionExportStrategyAdapter(def)); + } + + // Blueprint Services + services.AddSingleton(); + + // Workflow Storage Service + services.AddSingleton(); + + // Trigger Manager (constructor takes IPluginServer) + services.AddSingleton(); + // Expose TriggerManager under the ITriggerManager contract too + services.AddSingleton(sp => sp.GetRequiredService()); + + Log.Information("[AddKitXWorkflow] Workflow services registered."); + return services; + } +} diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Hosting/ServiceLocator.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Hosting/ServiceLocator.cs new file mode 100644 index 00000000..06b66ce5 --- /dev/null +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Hosting/ServiceLocator.cs @@ -0,0 +1,72 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace KitX.Workflow.Hosting; + +/// +/// Centralized service locator for the KitX.Workflow library. +/// +/// This is the workflow library's own counterpart of KitX.Core's ServiceLocator. +/// The host application (KitX.Core) must call with the +/// single shared after building it, so that workflow +/// code created outside of DI (e.g. builtin-function runtime methods, lazy singletons) +/// can resolve shared services (IPluginService, IDeviceServer, workflow services, ...) +/// through the same container, without taking a compile-time dependency on KitX.Core. +/// +public static class ServiceLocator +{ + private static IServiceProvider? _serviceProvider; + private static bool _isInitialized; + + /// + /// Gets the single IServiceProvider for the workflow library. + /// Throws if accessed before initialization. + /// + public static IServiceProvider ServiceProvider + { + get + { + if (_serviceProvider is null) + throw new InvalidOperationException( + "ServiceLocator has not been initialized. " + + "Call ServiceLocator.Initialize() first."); + + return _serviceProvider; + } + } + + /// Gets whether the locator has been initialized. + public static bool IsInitialized => _isInitialized; + + /// + /// Initializes the locator with the single shared IServiceProvider. + /// Should be called exactly ONCE during application startup, + /// after building the service provider. + /// + public static void Initialize(IServiceProvider serviceProvider) + { + if (_isInitialized) + { + Serilog.Log.Warning("[ServiceLocator] Initialize called more than once. Ignoring."); + return; + } + + _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + _isInitialized = true; + + Serilog.Log.Information("[ServiceLocator] Initialized. ServiceProvider HashCode: {HashCode}", + serviceProvider.GetHashCode()); + } + + /// + /// Gets a required service from the DI container. + /// Throws InvalidOperationException if the service is not registered. + /// + public static T GetRequiredService() where T : notnull + => ServiceProvider.GetRequiredService(); + + /// + /// Gets a service from the DI container, or null if not registered. + /// + public static T? GetService() where T : class + => ServiceProvider.GetService(); +} diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/KcsFileService.cs b/KitX Clients/KitX Workflow/KitX.Workflow/KcsFileService.cs similarity index 98% rename from KitX Clients/KitX Core/KitX.Core/Workflow/KcsFileService.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/KcsFileService.cs index d59794bb..ea0ac209 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/KcsFileService.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/KcsFileService.cs @@ -1,11 +1,11 @@ -using System.Text.Json; +using System.Text.Json; using KitX.Core.Contract.Workflow; using Serilog; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; -namespace KitX.Core.Workflow; +namespace KitX.Workflow; /// /// KCS文件服务实现 - 仅负责KCS文件的读写 diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/KitX.Workflow.csproj b/KitX Clients/KitX Workflow/KitX.Workflow/KitX.Workflow.csproj new file mode 100644 index 00000000..889d6be8 --- /dev/null +++ b/KitX Clients/KitX Workflow/KitX.Workflow/KitX.Workflow.csproj @@ -0,0 +1,47 @@ + + + + net10.0 + enable + enable + KitX.Workflow + + + + $(Version) + $(Version) + 24.10.$([System.DateTime]::UtcNow.Date.Subtract($([System.DateTime]::Parse("2024-02-07"))).TotalDays).$([System.Math]::Floor($([System.DateTime]::UtcNow.TimeOfDay.TotalMinutes))) + + + + + + <_Parameter1>KitX.Core.DI.Tests + + + <_Parameter1>KitX.Core.BluePrint.Test + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/RealPluginManager.cs b/KitX Clients/KitX Workflow/KitX.Workflow/RealPluginManager.cs similarity index 94% rename from KitX Clients/KitX Core/KitX.Core/Workflow/RealPluginManager.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/RealPluginManager.cs index da5e4522..af131df5 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/RealPluginManager.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/RealPluginManager.cs @@ -1,28 +1,26 @@ -using System.Collections.Concurrent; +using System.Collections.Concurrent; using System.Text; using System.Text.Json; using KitX.Core.Contract.Workflow; using KitX.Core.Contract.Plugin; using KitX.Core.Contract.Device; using KitX.Core.Contract.Event; -using KitX.Core.Device; using KitX.Core.Contract.Plugin.Events; -using KitX.Core.Event; -using KitX.Core.DI; +using KitX.Workflow.Hosting; using KitX.Shared.CSharp.Device; using KitX.Shared.CSharp.Plugin; using KitX.Shared.CSharp.WebCommand; using KitX.Shared.CSharp.WebCommand.Infos; using Serilog; -namespace KitX.Core.Workflow; +namespace KitX.Workflow; /// /// 真实的插件管理器实现,通过 WebSocket 与插件通信。 /// Implements both Contract.IPluginManager (primary, for BlockScripting) and /// KCS IPluginManager (legacy, for KCS pipeline compatibility). /// -public class RealPluginManager : IPluginManager +public class RealPluginManager : IPluginManager, IRealPluginManagerBridge { private readonly IPluginServer _pluginServer; private readonly IDeviceServer _deviceServer; @@ -42,38 +40,15 @@ public class RealPluginManager : IPluginManager private readonly ConcurrentDictionary> _pendingResponses = new(); /// - /// 构造函数(仅需 IPluginServer,其他依赖从 ServiceHost 解析) + /// 构造函数(仅需 IPluginServer,其他依赖从 ServiceLocator 解析) /// /// 插件服务器实例 public RealPluginManager(IPluginServer pluginServer) : this(pluginServer, - ServiceHost.IsInitialized ? ServiceHost.GetRequiredService() : new KitX.Core.Event.EventService(), - ServiceHost.IsInitialized ? ServiceHost.GetRequiredService() : null!, - ServiceHost.IsInitialized ? ServiceHost.GetRequiredService() : null!, - new DeviceHttpClient()) - { - } - - /// - /// 构造函数 - /// - /// 插件服务器实例 - /// 事件服务实例 - /// 设备发现服务实例 - public RealPluginManager(IPluginServer pluginServer, IEventService eventService, IDeviceDiscoveryService deviceDiscoveryService) - : this(pluginServer, eventService, deviceDiscoveryService, null!, new DeviceHttpClient()) - { - } - - /// - /// 构造函数 - /// - /// 插件服务器实例 - /// 事件服务实例 - /// 设备发现服务实例 - /// HTTP 客户端,用于跨设备调用 - public RealPluginManager(IPluginServer pluginServer, IEventService eventService, IDeviceDiscoveryService deviceDiscoveryService, IDeviceHttpClient deviceHttpClient) - : this(pluginServer, eventService, deviceDiscoveryService, null!, deviceHttpClient) + ServiceLocator.GetRequiredService(), + ServiceLocator.GetRequiredService(), + ServiceLocator.GetRequiredService(), + ServiceLocator.GetRequiredService()) { } @@ -91,7 +66,7 @@ public RealPluginManager(IPluginServer pluginServer, IEventService eventService, _eventService = eventService ?? throw new ArgumentNullException(nameof(eventService)); _deviceDiscoveryService = deviceDiscoveryService ?? throw new ArgumentNullException(nameof(deviceDiscoveryService)); _deviceServer = deviceServer; - _deviceHttpClient = deviceHttpClient ?? new DeviceHttpClient(); + _deviceHttpClient = deviceHttpClient ?? throw new ArgumentNullException(nameof(deviceHttpClient)); Log.Information("[RealPluginManager] Constructor. This HashCode: {ThisHashCode}, PluginServer HashCode: {PluginServerHashCode}", GetHashCode(), _pluginServer.GetHashCode()); @@ -100,7 +75,7 @@ public RealPluginManager(IPluginServer pluginServer, IEventService eventService, _pluginServer.PluginMessageReceived += OnPluginMessageReceived; // 订阅插件响应事件(当插件返回带RequestId的响应时触发) - _eventService.Subscribe(EventNames.PluginResponse, (sender, args) => OnPluginResponse(this, args)); + _eventService.Subscribe(WorkflowEventNames.PluginResponse, (sender, args) => OnPluginResponse(this, args)); } /// diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/TriggerManager.cs b/KitX Clients/KitX Workflow/KitX.Workflow/TriggerManager.cs similarity index 87% rename from KitX Clients/KitX Core/KitX.Core/Workflow/TriggerManager.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/TriggerManager.cs index e3b482a6..d4a2e362 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/TriggerManager.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/TriggerManager.cs @@ -1,21 +1,20 @@ -using System.Text.Json; +using System.Text.Json; using KitX.Core.Contract.Workflow; using KitX.Core.Contract.Event; using KitX.Core.Contract.Plugin; using KitX.Shared.CSharp.WebCommand; using KitX.Shared.CSharp.WebCommand.Infos; using Serilog; -using KitX.Core.DI; - +using KitX.Workflow.Hosting; using PluginMessageReceivedEventArgs = KitX.Core.Contract.Plugin.Events.PluginMessageReceivedEventArgs; -namespace KitX.Core.Workflow; +namespace KitX.Workflow; /// /// 管理触发器路由:从插件接收触发信号,路由到匹配的工作流并执行。 /// 触发器是纯信号(等同于"运行"按钮),不携带业务数据。 /// -public class TriggerManager +public class TriggerManager : ITriggerManager { private readonly IPluginServer _pluginServer; private readonly JsonSerializerOptions _serializerOptions = new() @@ -36,13 +35,12 @@ public class TriggerManager private readonly Dictionary _workflowTriggers = new(); /// - /// Creates a new trigger manager + /// Creates a new trigger manager bound to the given plugin server. /// - public TriggerManager() + /// Plugin server used to receive TriggerFired messages. + public TriggerManager(IPluginServer pluginServer) { - _pluginServer = ServiceHost.IsInitialized - ? ServiceHost.GetRequiredService() - : new KitX.Core.Device.PluginsServer(new KitX.Core.Event.EventService()); + _pluginServer = pluginServer ?? throw new ArgumentNullException(nameof(pluginServer)); _pluginServer.PluginMessageReceived += OnPluginMessageReceived; Log.Information("[TriggerManager] Initialized and subscribed to PluginMessageReceived"); } @@ -88,13 +86,13 @@ public async void InitializeFromPersistedWorkflows() { try { - if (!ServiceHost.IsInitialized) + if (!ServiceLocator.IsInitialized) { - Log.Error("[TriggerManager] Cannot initialize from persisted workflows: ServiceHost not initialized"); + Log.Error("[TriggerManager] Cannot initialize from persisted workflows: ServiceLocator not initialized"); return; } - var storageService = ServiceHost.GetRequiredService(); + var storageService = ServiceLocator.GetRequiredService(); var workflows = await storageService.DiscoverWorkflowsAsync(); foreach (var workflow in workflows) @@ -181,10 +179,10 @@ private void OnPluginMessageReceived(object? sender, PluginMessageReceivedEventA Log.Information("[TriggerManager] Triggering workflow: {WorkflowId}", workflowId); _ = System.Threading.Tasks.Task.Run(async () => { - bool success = await ServiceHost.GetRequiredService().RunWorkflowAsync(workflowId); - ServiceHost.GetRequiredService().Publish( - KitX.Core.Event.EventNames.WorkflowExecutionResult, - new KitX.Core.Contract.Event.WorkflowExecutionResultEventArgs( + bool success = await ServiceLocator.GetRequiredService().RunWorkflowAsync(workflowId); + ServiceLocator.GetRequiredService().Publish( + WorkflowEventNames.WorkflowExecutionResult, + new WorkflowExecutionResultEventArgs( workflowId, success, success ? null : "Workflow execution failed")); }); diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowCase.cs b/KitX Clients/KitX Workflow/KitX.Workflow/WorkflowCase.cs similarity index 91% rename from KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowCase.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/WorkflowCase.cs index 2d567ec1..b7bb2eda 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowCase.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/WorkflowCase.cs @@ -1,6 +1,6 @@ -using KitX.Core.Contract.Workflow; +using KitX.Core.Contract.Workflow; -namespace KitX.Core.Workflow; +namespace KitX.Workflow; /// /// Workflow case implementation diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowManagementService.cs b/KitX Clients/KitX Workflow/KitX.Workflow/WorkflowManagementService.cs similarity index 95% rename from KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowManagementService.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/WorkflowManagementService.cs index c1a09926..41ee3ebb 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowManagementService.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/WorkflowManagementService.cs @@ -1,7 +1,7 @@ -using KitX.Core.Contract.Workflow; +using KitX.Core.Contract.Workflow; using Serilog; -namespace KitX.Core.Workflow; +namespace KitX.Workflow; /// /// Workflow lifecycle management service. @@ -10,14 +10,16 @@ namespace KitX.Core.Workflow; internal class WorkflowManagementService : IWorkflowManagementService { private readonly WorkflowRuntimeState _state; - private readonly IBlockScriptService _blockScriptService; + // Holds the concrete impl so both the public IBlockScriptService (source-string methods) + // and the internal IBlockScriptPipelineService (parsed-model methods) are reachable. + private readonly BlockScriptServiceImpl _blockScriptService; /// /// Initializes a new instance of WorkflowManagementService. /// /// Shared runtime state. /// Block script execution service. - internal WorkflowManagementService(WorkflowRuntimeState state, IBlockScriptService blockScriptService) + internal WorkflowManagementService(WorkflowRuntimeState state, BlockScriptServiceImpl blockScriptService) { _state = state; _blockScriptService = blockScriptService; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowOutput.cs b/KitX Clients/KitX Workflow/KitX.Workflow/WorkflowOutput.cs similarity index 94% rename from KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowOutput.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/WorkflowOutput.cs index b8ecc238..7a52e097 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowOutput.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/WorkflowOutput.cs @@ -1,7 +1,7 @@ -using System.Collections.Concurrent; +using System.Collections.Concurrent; using System.Text; -namespace KitX.Core.Workflow; +namespace KitX.Workflow; /// /// 工作流脚本输出辅助类 diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowPluginService.cs b/KitX Clients/KitX Workflow/KitX.Workflow/WorkflowPluginService.cs similarity index 93% rename from KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowPluginService.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/WorkflowPluginService.cs index f0797b1f..e8ff5ed6 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowPluginService.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/WorkflowPluginService.cs @@ -1,11 +1,11 @@ -using KitX.Core.Contract.Workflow; +using KitX.Core.Contract.Workflow; using KitX.Core.Contract.Plugin; -using KitX.Core.DI; +using KitX.Workflow.Hosting; using KitX.Shared.CSharp.Plugin; using Microsoft.CodeAnalysis.CSharp; using Serilog; -namespace KitX.Core.Workflow; +namespace KitX.Workflow; /// /// Plugin coordination service for workflow script processing. @@ -31,9 +31,13 @@ public void InitializePluginManager() try { - var pluginServer = ServiceHost.IsInitialized - ? ServiceHost.GetRequiredService() - : new KitX.Core.Device.PluginsServer(new KitX.Core.Event.EventService()); + if (!ServiceLocator.IsInitialized) + { + Log.Error("[WorkflowPluginService] Cannot initialize plugin manager: ServiceLocator not initialized"); + return; + } + + var pluginServer = ServiceLocator.GetRequiredService(); var realPluginManager = new RealPluginManager(pluginServer); _state.IsParserInitialized = true; diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowRuntimeState.cs b/KitX Clients/KitX Workflow/KitX.Workflow/WorkflowRuntimeState.cs similarity index 92% rename from KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowRuntimeState.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/WorkflowRuntimeState.cs index 41f89f6f..32da92a7 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowRuntimeState.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/WorkflowRuntimeState.cs @@ -1,9 +1,9 @@ -using Csharpell.Core; +using Csharpell.Core; using KitX.Core.Contract.Workflow; -using KitX.Core.Workflow.BlockScripting; +using KitX.Workflow.BlockScripting; using KitX.Shared.CSharp.Plugin; -namespace KitX.Core.Workflow; +namespace KitX.Workflow; /// /// Shared state container for workflow runtime services. diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs b/KitX Clients/KitX Workflow/KitX.Workflow/WorkflowScriptService.cs similarity index 88% rename from KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/WorkflowScriptService.cs index cf1f48c5..f62d1786 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowScriptService.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/WorkflowScriptService.cs @@ -1,22 +1,25 @@ -using KitX.Core.Contract.Workflow; -using KitX.Core.DI; +using KitX.Core.Contract.Workflow; +using KitX.Workflow.Contract; +using KitX.Workflow.Hosting; using KitX.Shared.CSharp.Plugin; using Serilog; -namespace KitX.Core.Workflow; +namespace KitX.Workflow; /// /// Facade service that delegates to specialized workflow services. -/// Exposes all four workflow interfaces for backward compatibility. +/// Exposes the public workflow interfaces (IWorkflowManagementService, +/// IWorkflowPluginService, IBlockScriptService) plus the internal +/// IBlockScriptPipelineService for backward compatibility. /// public class WorkflowScriptService : IWorkflowManagementService, - IWorkflowPluginService, IBlockScriptService + IWorkflowPluginService, IBlockScriptService, IBlockScriptPipelineService { /// /// Gets the singleton facade instance. /// WorkflowScriptService is a facade that delegates to its static service graph, /// so it's not resolved from DI (IBlockScriptService is registered as BlockScriptServiceImpl). - /// Use ServiceHost.GetRequiredService<IWorkflowManagementService>() etc. for individual interfaces. + /// Use ServiceLocator.GetRequiredService<IWorkflowManagementService>() etc. for individual interfaces. /// public static WorkflowScriptService Instance { get; } = new(); @@ -27,8 +30,10 @@ public class WorkflowScriptService : IWorkflowManagementService, /// /// Service graph — built once at singleton instantiation. + /// Held as the concrete impl so both IBlockScriptService (public) and + /// IBlockScriptPipelineService (internal) methods are reachable. /// - private static IBlockScriptService? _blockScriptService; + private static BlockScriptServiceImpl? _blockScriptService; private static IWorkflowManagementService? _managementService; private static readonly IWorkflowPluginService PluginService; @@ -52,7 +57,7 @@ static WorkflowScriptService() /// Gets the BlockScriptService, creating it lazily once RealPluginManager is available. /// This ensures the BlockScriptService always has a valid RealPluginManager. /// - private static IBlockScriptService BlockScriptService + private static BlockScriptServiceImpl BlockScriptService { get { @@ -61,7 +66,7 @@ private static IBlockScriptService BlockScriptService RealPluginManager? rpm = null; try { - rpm = ServiceHost.GetRequiredService(); + rpm = ServiceLocator.GetRequiredService(); if (rpm != null) { SharedState.IsParserInitialized = true; @@ -142,13 +147,7 @@ public string ApplyConstantsToCode(string code, List constants public string MergeHelperFunctions(string mainCode, List helperFunctions) => PluginService.MergeHelperFunctions(mainCode, helperFunctions); - // --- IBlockScriptService --- - - public BlockScriptParseResult ParseBlockScript(string sourceCode) => - BlockScriptService.ParseBlockScript(sourceCode); - - public Task ParseBlockScriptAsync(string sourceCode) => - BlockScriptService.ParseBlockScriptAsync(sourceCode); + // --- IBlockScriptService (public: source-string based) --- public BlockScriptValidationResult ValidateBlockScript(string sourceCode) => BlockScriptService.ValidateBlockScript(sourceCode); @@ -156,12 +155,6 @@ public BlockScriptValidationResult ValidateBlockScript(string sourceCode) => public List ParseConstantsFromBlockScript(string sourceCode) => BlockScriptService.ParseConstantsFromBlockScript(sourceCode); - public Task ExecuteBlockScriptAsync( - BlockScript script, - Dictionary? parameters = null, - CancellationToken cancellationToken = default) => - BlockScriptService.ExecuteBlockScriptAsync(script, parameters, cancellationToken); - public Task ExecuteBlockScriptAsync( string sourceCode, Dictionary? parameters = null, @@ -181,9 +174,24 @@ public Task ExecuteBlockScriptAsync( CancellationToken cancellationToken = default) => BlockScriptService.ExecuteBlockScriptAsync(sourceCode, helperFunctions, constantOverrides, cancellationToken); - public Task CompileAndPersistAsync(BlockScript script, string workflowId) => - BlockScriptService.CompileAndPersistAsync(script, workflowId); - public int PreloadCompiledScripts(string workflowId) => BlockScriptService.PreloadCompiledScripts(workflowId); + + // --- IBlockScriptPipelineService (internal: parsed-model based) --- + + public BlockScriptParseResult ParseBlockScript(string sourceCode) => + BlockScriptService.ParseBlockScript(sourceCode); + + public Task ParseBlockScriptAsync(string sourceCode) => + BlockScriptService.ParseBlockScriptAsync(sourceCode); + + public Task ExecuteBlockScriptAsync( + BlockScript script, + Dictionary? parameters = null, + CancellationToken cancellationToken = default) => + BlockScriptService.ExecuteBlockScriptAsync(script, parameters, cancellationToken); + + public Task CompileAndPersistAsync(BlockScript script, string workflowId) => + BlockScriptService.CompileAndPersistAsync(script, workflowId); } + diff --git a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowStorageService.cs b/KitX Clients/KitX Workflow/KitX.Workflow/WorkflowStorageService.cs similarity index 97% rename from KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowStorageService.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/WorkflowStorageService.cs index ac7752b8..df75bb1f 100644 --- a/KitX Clients/KitX Core/KitX.Core/Workflow/WorkflowStorageService.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/WorkflowStorageService.cs @@ -1,9 +1,9 @@ -using System.Text.Json; +using System.Text.Json; using KitX.Core.Contract.Workflow; -using KitX.Core.DI; +using KitX.Workflow.Hosting; using Serilog; -namespace KitX.Core.Workflow; +namespace KitX.Workflow; /// /// Workflow storage service implementation - manages workflow file persistence @@ -11,17 +11,17 @@ namespace KitX.Core.Workflow; public class WorkflowStorageService : IWorkflowStorageService { /// - /// Gets the singleton instance (resolves from ServiceHost when available). + /// Gets the singleton instance (resolves from ServiceLocator when available). /// Internal code should use constructor injection instead. /// public static WorkflowStorageService Instance { get { - if (ServiceHost.IsInitialized) - return (WorkflowStorageService)ServiceHost.GetRequiredService(); - Log.Error("[WorkflowStorageService] Instance: ServiceHost not initialized! Returning orphan instance — " + - "this indicates a DI initialization order bug. Use ServiceHost/constructor injection instead."); + if (ServiceLocator.IsInitialized) + return (WorkflowStorageService)ServiceLocator.GetRequiredService(); + Log.Error("[WorkflowStorageService] Instance: ServiceLocator not initialized! Returning orphan instance — " + + "this indicates a DI initialization order bug. Use ServiceLocator/constructor injection instead."); return new WorkflowStorageService(); } } diff --git a/KitX Standard b/KitX Standard index 51da1348..6d12db26 160000 --- a/KitX Standard +++ b/KitX Standard @@ -1 +1 @@ -Subproject commit 51da1348a5cc9455adb94dd38a684ba41bdf63e2 +Subproject commit 6d12db2687a6c2d07728dbe44702fd12cfec39ee diff --git a/KitX.sln b/KitX.sln index 5480b224..d6ae8786 100644 --- a/KitX.sln +++ b/KitX.sln @@ -157,6 +157,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KitX.Core.DI.Tests", "KitX EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KitX.Core.BluePrint.Test", "KitX Clients\KitX Core\KitX.Core.BluePrint.Test\KitX.Core.BluePrint.Test.csproj", "{3C461B17-B9A4-97E1-9A62-79BC972F7A13}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "KitX Workflow", "KitX Workflow", "{5B220184-3539-443A-A12B-4381F4817D13}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KitX.Workflow", "KitX Clients\KitX Workflow\KitX.Workflow\KitX.Workflow.csproj", "{E4438FD7-74FB-417C-B4F1-FC5C6040F5FC}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -931,6 +935,26 @@ Global {3C461B17-B9A4-97E1-9A62-79BC972F7A13}.Release|x64.Build.0 = Release|Any CPU {3C461B17-B9A4-97E1-9A62-79BC972F7A13}.Release|x86.ActiveCfg = Release|Any CPU {3C461B17-B9A4-97E1-9A62-79BC972F7A13}.Release|x86.Build.0 = Release|Any CPU + {E4438FD7-74FB-417C-B4F1-FC5C6040F5FC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E4438FD7-74FB-417C-B4F1-FC5C6040F5FC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E4438FD7-74FB-417C-B4F1-FC5C6040F5FC}.Debug|ARM.ActiveCfg = Debug|Any CPU + {E4438FD7-74FB-417C-B4F1-FC5C6040F5FC}.Debug|ARM.Build.0 = Debug|Any CPU + {E4438FD7-74FB-417C-B4F1-FC5C6040F5FC}.Debug|ARM64.ActiveCfg = Debug|Any CPU + {E4438FD7-74FB-417C-B4F1-FC5C6040F5FC}.Debug|ARM64.Build.0 = Debug|Any CPU + {E4438FD7-74FB-417C-B4F1-FC5C6040F5FC}.Debug|x64.ActiveCfg = Debug|Any CPU + {E4438FD7-74FB-417C-B4F1-FC5C6040F5FC}.Debug|x64.Build.0 = Debug|Any CPU + {E4438FD7-74FB-417C-B4F1-FC5C6040F5FC}.Debug|x86.ActiveCfg = Debug|Any CPU + {E4438FD7-74FB-417C-B4F1-FC5C6040F5FC}.Debug|x86.Build.0 = Debug|Any CPU + {E4438FD7-74FB-417C-B4F1-FC5C6040F5FC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E4438FD7-74FB-417C-B4F1-FC5C6040F5FC}.Release|Any CPU.Build.0 = Release|Any CPU + {E4438FD7-74FB-417C-B4F1-FC5C6040F5FC}.Release|ARM.ActiveCfg = Release|Any CPU + {E4438FD7-74FB-417C-B4F1-FC5C6040F5FC}.Release|ARM.Build.0 = Release|Any CPU + {E4438FD7-74FB-417C-B4F1-FC5C6040F5FC}.Release|ARM64.ActiveCfg = Release|Any CPU + {E4438FD7-74FB-417C-B4F1-FC5C6040F5FC}.Release|ARM64.Build.0 = Release|Any CPU + {E4438FD7-74FB-417C-B4F1-FC5C6040F5FC}.Release|x64.ActiveCfg = Release|Any CPU + {E4438FD7-74FB-417C-B4F1-FC5C6040F5FC}.Release|x64.Build.0 = Release|Any CPU + {E4438FD7-74FB-417C-B4F1-FC5C6040F5FC}.Release|x86.ActiveCfg = Release|Any CPU + {E4438FD7-74FB-417C-B4F1-FC5C6040F5FC}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -985,6 +1009,8 @@ Global {641562BA-D2B5-4799-8712-11C23912A79B} = {C4C2D1CC-294B-4C93-8A64-34D5212AD74E} {9D8CEB24-748C-0392-629C-45342937C32A} = {C4C2D1CC-294B-4C93-8A64-34D5212AD74E} {3C461B17-B9A4-97E1-9A62-79BC972F7A13} = {C4C2D1CC-294B-4C93-8A64-34D5212AD74E} + {5B220184-3539-443A-A12B-4381F4817D13} = {673CF32C-65BF-4EB3-83D3-47FEC77B47A0} + {E4438FD7-74FB-417C-B4F1-FC5C6040F5FC} = {5B220184-3539-443A-A12B-4381F4817D13} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {09BBC227-F41B-4D10-9E38-0EEE07ED17BC} From e8beb3c7230f231899f7f7896bfff34206a0b5ed Mon Sep 17 00:00:00 2001 From: StarInk Date: Wed, 17 Jun 2026 14:15:30 +0200 Subject: [PATCH 081/110] =?UTF-8?q?=F0=9F=94=A7=20Fix(Workflow):=20?= =?UTF-8?q?=E8=A1=A5=E6=B3=A8=E5=86=8C=20IRealPluginManagerBridge=20?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E6=98=A0=E5=B0=84-=E4=BF=AE=E5=A4=8D=20Dashb?= =?UTF-8?q?oard=20=E5=90=AF=E5=8A=A8=E6=97=B6=20GetRequiredService=20?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E3=80=81ServiceLocator=20=E4=B8=8E=20Trigger?= =?UTF-8?q?Manager=20=E6=9E=84=E9=80=A0=E6=B3=A8=E5=85=A5=E7=BB=8F?= =?UTF-8?q?=E9=9B=86=E6=88=90=E6=B5=8B=E8=AF=95=E9=AA=8C=E8=AF=81=E9=80=9A?= =?UTF-8?q?=E8=BF=87(4=E5=B7=A5=E4=BD=9C=E6=B5=81=E8=AF=BB=E5=8F=96/?= =?UTF-8?q?=E8=A7=A6=E5=8F=91=E5=99=A8=E6=B3=A8=E5=86=8C/RealPluginManager?= =?UTF-8?q?=E5=8D=95=E4=BE=8B=E5=85=B1=E4=BA=AB=E5=9D=87=E6=AD=A3=E5=B8=B8?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../KitX.Workflow/Hosting/ServiceCollectionExtensions.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Hosting/ServiceCollectionExtensions.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Hosting/ServiceCollectionExtensions.cs index da82243c..0f364ad6 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Hosting/ServiceCollectionExtensions.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Hosting/ServiceCollectionExtensions.cs @@ -57,6 +57,9 @@ public static IServiceCollection AddKitXWorkflow(this IServiceCollection service var deviceServer = provider.GetRequiredService(); return new RealPluginManager(pluginServer, eventService, deviceDiscoveryService, deviceServer, provider.GetRequiredService()); }); + // Expose the same RealPluginManager instance under the bridge contract so the + // host (Dashboard) can pre-resolve it to force eager singleton construction. + services.AddSingleton(sp => sp.GetRequiredService()); // KCS File Service services.AddSingleton(); From 53b95419b001bdd5e722311fa4a21f96e4787ecc Mon Sep 17 00:00:00 2001 From: StarInk Date: Wed, 17 Jun 2026 17:24:38 +0200 Subject: [PATCH 082/110] =?UTF-8?q?=F0=9F=93=9D=20Chore(Submodules):=20Syn?= =?UTF-8?q?c.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- KitX Clients/KitX Dashboard | 2 +- KitX Standard | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/KitX Clients/KitX Dashboard b/KitX Clients/KitX Dashboard index cf980d08..7a62cc71 160000 --- a/KitX Clients/KitX Dashboard +++ b/KitX Clients/KitX Dashboard @@ -1 +1 @@ -Subproject commit cf980d0889925900824839d4afe7c032336f4150 +Subproject commit 7a62cc717f3f1614b5d5d4a0b0899fe2a213fad4 diff --git a/KitX Standard b/KitX Standard index 6d12db26..bed87569 160000 --- a/KitX Standard +++ b/KitX Standard @@ -1 +1 @@ -Subproject commit 6d12db2687a6c2d07728dbe44702fd12cfec39ee +Subproject commit bed875698d6e3beb5f622359b8c93cea3dd0e919 From bb0794016e186e957bf122c15cf380761e63fdb2 Mon Sep 17 00:00:00 2001 From: StarInk Date: Wed, 17 Jun 2026 20:17:42 +0200 Subject: [PATCH 083/110] =?UTF-8?q?=F0=9F=93=9D=20Chore(Submodules):=20Syn?= =?UTF-8?q?c.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- KitX Clients/KitX Dashboard | 2 +- KitX Standard | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/KitX Clients/KitX Dashboard b/KitX Clients/KitX Dashboard index 7a62cc71..f49d187f 160000 --- a/KitX Clients/KitX Dashboard +++ b/KitX Clients/KitX Dashboard @@ -1 +1 @@ -Subproject commit 7a62cc717f3f1614b5d5d4a0b0899fe2a213fad4 +Subproject commit f49d187ff2b54efcce76ad9c5f4ca507cc75a9c0 diff --git a/KitX Standard b/KitX Standard index bed87569..c1671fa9 160000 --- a/KitX Standard +++ b/KitX Standard @@ -1 +1 @@ -Subproject commit bed875698d6e3beb5f622359b8c93cea3dd0e919 +Subproject commit c1671fa964ca435501b5d8bc3e81f3d767978a1d From 1f0cbb447c5045a28668df6d5ec07a473aff9685 Mon Sep 17 00:00:00 2001 From: StarInk Date: Wed, 17 Jun 2026 22:04:49 +0200 Subject: [PATCH 084/110] =?UTF-8?q?=F0=9F=93=9D=20Chore(.cheese,=20commit-?= =?UTF-8?q?template):=20=E6=B3=A8=E5=86=8C=20Crequency/NodifyM.Avalonia=20?= =?UTF-8?q?fork=20=E4=B8=BA=20reference?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 ReferenceItem 指向 Crequency 组织的 NodifyM.Avalonia fork 的 ava11 分支, 承载框选崩溃的 SelectItem null-guard 补丁(上游 v1.1.9/v12 均未修复)。 Dashboard 子模块指针同步更新为携带崩溃修复 + 前端优化的版本。另更新commit-template.txt以更清晰地展示emoji和提交类型的对应关系。 --- .cheese/references.cs | 9 +++++++++ KitX Clients/KitX Dashboard | 2 +- commit-template.txt | 1 + 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.cheese/references.cs b/.cheese/references.cs index 3f7e747c..b66f3160 100644 --- a/.cheese/references.cs +++ b/.cheese/references.cs @@ -55,4 +55,13 @@ Type = ReferenceType.GitRepo, InSubmodule = true, }, + new ReferenceItem + { + Name = "NodifyM.Avalonia", + Location = "Reference/NodifyM.Avalonia", + Url = "git@github.com:Crequency/NodifyM.Avalonia.git", + Branch = "ava11", + RemoteBranch = "origin/ava11", + Type = ReferenceType.GitRepo, + }, }; diff --git a/KitX Clients/KitX Dashboard b/KitX Clients/KitX Dashboard index f49d187f..11e7d6e1 160000 --- a/KitX Clients/KitX Dashboard +++ b/KitX Clients/KitX Dashboard @@ -1 +1 @@ -Subproject commit f49d187ff2b54efcce76ad9c5f4ca507cc75a9c0 +Subproject commit 11e7d6e1eff43e89e9562457b74ac2c5d65cd750 diff --git a/commit-template.txt b/commit-template.txt index d711c535..8ba60046 100644 --- a/commit-template.txt +++ b/commit-template.txt @@ -3,6 +3,7 @@ # (): # : 💾/🔧/📄/🎇/🧩/✅/❌/📝/📦/🐛 # : Feat/Fix/Docs/Style/Refactor/Test/Test/Chore/Struct/Bug +# 💾-Feat, 🔧-Fix, 📄-Docs, 🎇-Style, 🧩-Refactor, ✅-Test, ❌-Bug, 📝-Chore, 📦-Struct, 🐛-Bug # demo: 💾 📝 📦 Feat, Chore, Struct(Scope): issue or changelog # docs: https://docs.catrol.cn/rules/team/git/#%25E6%258F%2590%25E4%25BA%25A4%25E6%25B6%2588%25E6%2581%25AF%25E8%25A7%2584%25E8%258C%2583 From 0a9e2b1cb479a044cac106ac1dc5b4df34fefec5 Mon Sep 17 00:00:00 2001 From: StarInk Date: Wed, 17 Jun 2026 23:43:52 +0200 Subject: [PATCH 085/110] =?UTF-8?q?=F0=9F=93=84=20Docs(commit-template):?= =?UTF-8?q?=20Add=20some=20notes.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- commit-template.txt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/commit-template.txt b/commit-template.txt index 8ba60046..2024cb8d 100644 --- a/commit-template.txt +++ b/commit-template.txt @@ -3,7 +3,9 @@ # (): # : 💾/🔧/📄/🎇/🧩/✅/❌/📝/📦/🐛 # : Feat/Fix/Docs/Style/Refactor/Test/Test/Chore/Struct/Bug -# 💾-Feat, 🔧-Fix, 📄-Docs, 🎇-Style, 🧩-Refactor, ✅-Test, ❌-Bug, 📝-Chore, 📦-Struct, 🐛-Bug +# 💾-Feat, 🔧-Fix, 📄-Docs, 🎇-Style, 🧩-Refactor, ✅-Test, ❌-Test, 📝-Chore, 📦-Struct, 🐛-Bug +# Note: Two types (Test) with different emojis (✅/❌) to distinguish positive vs negative test changes. +# Note: "Fix" vs "Bug" - "Fix" is for code changes that fix bugs, while "Bug" is for reporting bugs without code changes. # demo: 💾 📝 📦 Feat, Chore, Struct(Scope): issue or changelog # docs: https://docs.catrol.cn/rules/team/git/#%25E6%258F%2590%25E4%25BA%25A4%25E6%25B6%2588%25E6%2581%25AF%25E8%25A7%2584%25E8%258C%2583 From 6db64862ea9d9f9b8b5926a8ac2066d9a05606e3 Mon Sep 17 00:00:00 2001 From: StarInk Date: Thu, 18 Jun 2026 09:49:35 +0200 Subject: [PATCH 086/110] =?UTF-8?q?=F0=9F=94=A7=20Fix(Build):=20=E8=A7=84?= =?UTF-8?q?=E8=8C=83=E5=8C=96=20Linux=20=E4=B8=8B=20Reference=20=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E5=A4=A7=E5=B0=8F=E5=86=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 KitX.Core.csproj 与 KitX.sln 中 Csharpell 引用目录的大小写 从 `Reference\CSharpell\` 修正为 `Reference\Csharpell\`。 Windows 文件系统大小写不敏感,故无影响;但 Linux 文件系统大小写 敏感,此前大小写不一致会导致无法解析 Reference 目录,从而破坏 Linux 构建。 复刻自 fix/linux-reference-casing 分支(xihale 的工作),丢弃了该 分支上不相关的 Config/*.json 取消跟踪、子模块指针 bump、 Common.Algorithm.Interop 移除及冲突合并历史。 Co-authored-by: xihale --- KitX Clients/KitX Core/KitX.Core/KitX.Core.csproj | 4 ++-- KitX.sln | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/KitX Clients/KitX Core/KitX.Core/KitX.Core.csproj b/KitX Clients/KitX Core/KitX.Core/KitX.Core.csproj index 4d5e15b9..f35799ac 100644 --- a/KitX Clients/KitX Core/KitX.Core/KitX.Core.csproj +++ b/KitX Clients/KitX Core/KitX.Core/KitX.Core.csproj @@ -48,8 +48,8 @@ - - + + diff --git a/KitX.sln b/KitX.sln index d6ae8786..f50008d6 100644 --- a/KitX.sln +++ b/KitX.sln @@ -111,9 +111,9 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TestPlugin.Winform.Core", " EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TestPlugin.WPF.Core", "KitX SDK\KitX Plugins\TestPlugin.WPF.Core\TestPlugin.WPF.Core.csproj", "{73C9C918-2222-4425-9214-887BED47C77F}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Csharpell", "Reference\CSharpell\Csharpell\Csharpell.csproj", "{657F7502-43A9-4FC0-8026-1C8F2D103DBA}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Csharpell", "Reference\Csharpell\Csharpell\Csharpell.csproj", "{657F7502-43A9-4FC0-8026-1C8F2D103DBA}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Csharpell.Core", "Reference\CSharpell\Csharpell.Core\Csharpell.Core.csproj", "{C161612B-8AE2-4F1C-A29E-875C411F31D1}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Csharpell.Core", "Reference\Csharpell\Csharpell.Core\Csharpell.Core.csproj", "{C161612B-8AE2-4F1C-A29E-875C411F31D1}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "KitX File Formats", "KitX File Formats", "{A0152BF5-A875-40C0-BD57-D99DDCDB5D4C}" EndProject From 71d33e6df158f644027e0a47b48fc0b4b886a048 Mon Sep 17 00:00:00 2001 From: StarInk Date: Thu, 18 Jun 2026 09:59:31 +0200 Subject: [PATCH 087/110] =?UTF-8?q?=F0=9F=93=9D=20Chore(sln):=20=E4=BB=8Es?= =?UTF-8?q?ln=E4=B8=AD=E7=A7=BB=E9=99=A4=E5=B7=B2=E5=A4=B1=E6=95=88?= =?UTF-8?q?=E7=9A=84Common.Algorithm.Interop=20Co-authored-by:=20xihale=20?= =?UTF-8?q??= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- KitX.sln | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/KitX.sln b/KitX.sln index f50008d6..efbf79b5 100644 --- a/KitX.sln +++ b/KitX.sln @@ -26,8 +26,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Common.Update.Manual", "Ref EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Common.Update.Replacer", "Reference\Common.Update\Common.Update.Replacer\Common.Update.Replacer.csproj", "{00E4BCFB-9FBC-44BC-8AA5-3A24E790B23F}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Common.Algorithm.Interop", "Reference\Common.Algorithm\Common.Algorithm.Interop\Common.Algorithm.Interop.csproj", "{9EC8B0AF-3AD8-49FB-A9DA-154E6EA617C1}" -EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Common.Activity", "Reference\Common.Activity\Common.Activity\Common.Activity.csproj", "{C2489F7B-49C0-418C-8A63-0A6C134A3E08}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "KitX Clients", "KitX Clients", "{673CF32C-65BF-4EB3-83D3-47FEC77B47A0}" @@ -255,26 +253,6 @@ Global {00E4BCFB-9FBC-44BC-8AA5-3A24E790B23F}.Release|x64.Build.0 = Release|Any CPU {00E4BCFB-9FBC-44BC-8AA5-3A24E790B23F}.Release|x86.ActiveCfg = Release|Any CPU {00E4BCFB-9FBC-44BC-8AA5-3A24E790B23F}.Release|x86.Build.0 = Release|Any CPU - {9EC8B0AF-3AD8-49FB-A9DA-154E6EA617C1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {9EC8B0AF-3AD8-49FB-A9DA-154E6EA617C1}.Debug|Any CPU.Build.0 = Debug|Any CPU - {9EC8B0AF-3AD8-49FB-A9DA-154E6EA617C1}.Debug|ARM.ActiveCfg = Debug|Any CPU - {9EC8B0AF-3AD8-49FB-A9DA-154E6EA617C1}.Debug|ARM.Build.0 = Debug|Any CPU - {9EC8B0AF-3AD8-49FB-A9DA-154E6EA617C1}.Debug|ARM64.ActiveCfg = Debug|Any CPU - {9EC8B0AF-3AD8-49FB-A9DA-154E6EA617C1}.Debug|ARM64.Build.0 = Debug|Any CPU - {9EC8B0AF-3AD8-49FB-A9DA-154E6EA617C1}.Debug|x64.ActiveCfg = Debug|Any CPU - {9EC8B0AF-3AD8-49FB-A9DA-154E6EA617C1}.Debug|x64.Build.0 = Debug|Any CPU - {9EC8B0AF-3AD8-49FB-A9DA-154E6EA617C1}.Debug|x86.ActiveCfg = Debug|Any CPU - {9EC8B0AF-3AD8-49FB-A9DA-154E6EA617C1}.Debug|x86.Build.0 = Debug|Any CPU - {9EC8B0AF-3AD8-49FB-A9DA-154E6EA617C1}.Release|Any CPU.ActiveCfg = Release|Any CPU - {9EC8B0AF-3AD8-49FB-A9DA-154E6EA617C1}.Release|Any CPU.Build.0 = Release|Any CPU - {9EC8B0AF-3AD8-49FB-A9DA-154E6EA617C1}.Release|ARM.ActiveCfg = Release|Any CPU - {9EC8B0AF-3AD8-49FB-A9DA-154E6EA617C1}.Release|ARM.Build.0 = Release|Any CPU - {9EC8B0AF-3AD8-49FB-A9DA-154E6EA617C1}.Release|ARM64.ActiveCfg = Release|Any CPU - {9EC8B0AF-3AD8-49FB-A9DA-154E6EA617C1}.Release|ARM64.Build.0 = Release|Any CPU - {9EC8B0AF-3AD8-49FB-A9DA-154E6EA617C1}.Release|x64.ActiveCfg = Release|Any CPU - {9EC8B0AF-3AD8-49FB-A9DA-154E6EA617C1}.Release|x64.Build.0 = Release|Any CPU - {9EC8B0AF-3AD8-49FB-A9DA-154E6EA617C1}.Release|x86.ActiveCfg = Release|Any CPU - {9EC8B0AF-3AD8-49FB-A9DA-154E6EA617C1}.Release|x86.Build.0 = Release|Any CPU {C2489F7B-49C0-418C-8A63-0A6C134A3E08}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C2489F7B-49C0-418C-8A63-0A6C134A3E08}.Debug|Any CPU.Build.0 = Debug|Any CPU {C2489F7B-49C0-418C-8A63-0A6C134A3E08}.Debug|ARM.ActiveCfg = Debug|Any CPU @@ -964,7 +942,6 @@ Global {F926585B-E9EC-4158-857E-E6310C70DCEE} = {F021B1E7-44AC-469B-AB4D-36541902C694} {80C5352A-5ECD-4CC1-B6B2-033E624524DA} = {F021B1E7-44AC-469B-AB4D-36541902C694} {00E4BCFB-9FBC-44BC-8AA5-3A24E790B23F} = {F021B1E7-44AC-469B-AB4D-36541902C694} - {9EC8B0AF-3AD8-49FB-A9DA-154E6EA617C1} = {F021B1E7-44AC-469B-AB4D-36541902C694} {C2489F7B-49C0-418C-8A63-0A6C134A3E08} = {F021B1E7-44AC-469B-AB4D-36541902C694} {0194E969-1457-4C16-AFAB-D2753DFA07F0} = {673CF32C-65BF-4EB3-83D3-47FEC77B47A0} {42654B2B-4B5A-4B78-BC0A-8D3B3D82EC9A} = {37E33B90-88B7-4404-9D42-D672A2916E6C} From a1f5e9e9aced4a6eb21052ae343d90141e60dcff Mon Sep 17 00:00:00 2001 From: StarInk Date: Thu, 18 Jun 2026 10:43:58 +0200 Subject: [PATCH 088/110] =?UTF-8?q?=F0=9F=93=9D=20Chore(Submodules):=20Syn?= =?UTF-8?q?c.=20For=20solving=20the=20merge=20conflicts.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- KitX SDK | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/KitX SDK b/KitX SDK index 17938752..19732888 160000 --- a/KitX SDK +++ b/KitX SDK @@ -1 +1 @@ -Subproject commit 17938752f7d42d1d1dde718241a88eef4cc07d1b +Subproject commit 1973288813e3c182b5448b55caaae53ce159fd19 From 0d1fc02fe4d964e0f5bad99ad1aa78eabb482227 Mon Sep 17 00:00:00 2001 From: StarInk Date: Fri, 19 Jun 2026 15:18:44 +0200 Subject: [PATCH 089/110] =?UTF-8?q?=F0=9F=92=BE=F0=9F=A7=A9=20Feat,=20Refa?= =?UTF-8?q?ctor(CFG,=20Debug,=20Blueprint):=20Enhance=20BlockScript=20comp?= =?UTF-8?q?ilation=20and=20conversion=20diagnostics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Updated ScriptCompilationBackend to return human-readable diagnostics on compilation failure. - Refactored BranchFunction, FlipFunction, LoopFunction, and ToLoopCondFunction to utilize new string literal extraction method. - Introduced ConversionDiagnostics model to collect and format user-facing diagnostics during parsing and conversion. - Enhanced BS2CFGConverter and BlockScriptToBlueprintConverter to surface diagnostics from parsing and conversion processes. - Added diagnostics tracking in PipelineContext and ConversionContext for better error reporting. - Updated solution files to include new AI and FloatAssist plugins. --- .../KitX.Core.BluePrint.Test/Program.cs | 139 ++++++++++++++++++ KitX Clients/KitX Dashboard | 2 +- .../BlockScripting/BlockScriptExecutor.cs | 41 +++++- .../BlockScripting/BlockScriptParser.cs | 15 +- .../BlockScripting/BlockStatementExtractor.cs | 61 +++++--- .../BlockScripting/BuiltinFunctionRegistry.cs | 4 +- .../BlockScripting/CSCompiler.cs | 52 ++++++- .../ScriptCompilationBackend.cs | 21 ++- .../BuiltinFunctions/BranchFunction.cs | 7 +- .../BuiltinFunctions/FlipFunction.cs | 7 +- .../BuiltinFunctions/LoopFunction.cs | 7 +- .../BuiltinFunctions/PluginCallFunction.cs | 9 +- .../BuiltinFunctions/ToLoopCondFunction.cs | 5 +- .../Contract/Models/ConversionDiagnostics.cs | 112 ++++++++++++++ .../Models/Results/BlockScriptParseResult.cs | 7 + .../Models/Statements/ExpressionStatement.cs | 19 +++ .../Conversion/BS2CFGConverter.cs | 66 ++++++++- .../BlockScriptToBlueprintConverter.cs | 14 +- .../BlueprintToBlockScriptConverter.cs | 7 + .../Conversion/CFG2CSGenerator.cs | 23 ++- .../Conversion/ConversionContext.cs | 5 + .../Conversion/PipelineContext.cs | 6 + KitX.sln | 69 +++++++++ KitX.slnx | 2 + 24 files changed, 633 insertions(+), 67 deletions(-) create mode 100644 KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/ConversionDiagnostics.cs diff --git a/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs b/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs index 6e36cbbc..bf518739 100644 --- a/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs +++ b/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs @@ -223,6 +223,77 @@ public static void Main(string[] args) Console.WriteLine("└──────────────────────────────────────────┘\n"); RunQualifiedPluginCallTest(parser); } + + if (ShouldRunTest("T")) + { + Console.WriteLine("\n┌──────────────────────────────────────────────┐"); + Console.WriteLine("│ Test T: Nested PluginCall in Set │"); + Console.WriteLine("└──────────────────────────────────────────────┘\n"); + RunNestedPluginCallTest(parser); + } + + if (ShouldRunTest("U")) + { + Console.WriteLine("\n┌──────────────────────────────────────────────┐"); + Console.WriteLine("│ Test U: Conversion Diagnostics │"); + Console.WriteLine("└──────────────────────────────────────────────┘\n"); + RunDiagnosticsTest(converter, helpers, "Test U"); + } + } + + // ────────────────────────────────────────────── + // Test U: Conversion diagnostics surface user errors instead of silently dropping them. + // Guards the P0 diagnostics channel: a clean script yields no diagnostics, and dead code + // after a flow-control statement (BlockScript §6) yields a BS_DEAD_CODE warning. + // ────────────────────────────────────────────── + private static void RunDiagnosticsTest( + BlockScriptToBlueprintConverter converter, + List helpers, + string label) + { + bool allPassed = true; + + // U1: a valid script produces NO diagnostics. + try + { + converter.Convert(GetRawNestedScript(), helpers); + var diag = converter.LastDiagnostics; + bool clean = diag == null || (!diag.HasErrors && !diag.HasWarnings); + Console.WriteLine($" [U1] Clean script diagnostics: {(clean ? "none (expected)" : diag!.Format())}"); + if (!clean) allPassed = false; + } + catch (Exception ex) + { + Console.WriteLine($" [U1] FAILED: {ex.Message}"); + allPassed = false; + } + + // U2: dead code after a flow-control statement → BS_DEAD_CODE warning (non-fatal). + try + { + var src = @"#MainBlock +Print(""before""); +NextBlock = Branch(true, ""T"", ""F""); +Print(""dead""); + +#Block T +Print(""t""); + +#Block F +Print(""f"");"; + converter.Convert(src, helpers); + var diag = converter.LastDiagnostics; + bool hasDeadCode = diag != null && diag.Items.Any(d => d.Code == "BS_DEAD_CODE"); + Console.WriteLine($" [U2] Dead-code warning emitted: {hasDeadCode}"); + if (!hasDeadCode) allPassed = false; + } + catch (Exception ex) + { + Console.WriteLine($" [U2] FAILED: {ex.Message}"); + allPassed = false; + } + + Console.WriteLine($"\n[{label}] {(allPassed ? "PASS - diagnostics channel works" : "FAIL - see above")}"); } private static void ParseArgs(string[] args) @@ -1829,4 +1900,72 @@ private static void RunQualifiedPluginCallTest(IBlockScriptParser parser) Console.WriteLine($"[Test S] FAILED: {ex.Message}\n {ex.StackTrace}"); } } + + // Test T: Nested PluginCall inside Set — does the BS→CFG expander flatten it + // into a temp PubVar + standalone PluginCall, the way it does for Helper functions + // (Test B/C exercise HelperFuncAdd(Get(...), 1))? If yes, the generated C# should + // compile cleanly. If no, the nested PluginCall is emitted verbatim and fails with + // CS0103 'PluginCall does not exist' — a real converter gap worth fixing. + private static void RunNestedPluginCallTest(IBlockScriptParser parser) + { + Console.WriteLine("[Test T] Nested PluginCall as Set argument — expander coverage check"); + + // Form 1: builtin PluginCall(plugin, method, args) nested in Set. + var sourceBuiltin = @" +#PubVarBlock +dynamic result; + +#MainBlock +Set(""result"", PluginCall(""TestPlugin"", ""Echo"", ""hello"")); +Print(Get(""result"")); +"; + + // Form 2: dotted Plugin.Method(args) nested in Set. + var sourceDotted = @" +#PubVarBlock +dynamic result; + +#MainBlock +Set(""result"", TestPlugin.Echo(""hello"")); +Print(Get(""result"")); +"; + + int fails = 0; + + foreach (var (label, src) in new[] { ("builtin", sourceBuiltin), ("dotted", sourceDotted) }) + { + try + { + var pr = parser.Parse(src); + if (!pr.IsSuccess || pr.Script == null) + { + Console.WriteLine($" [{label}] FAIL: parse error: {pr.ErrorMessage}"); + fails++; + continue; + } + pr.Script.HelperFunctions = new List(); + + var compiler = new CSCompiler(); + var compiled = compiler.CompileScript(pr.Script, workflowId: null, out var errors); + if (compiled != null) + { + Console.WriteLine($" [{label}] PASS: nested PluginCall expanded + compiled"); + } + else + { + Console.WriteLine($" [{label}] FAIL: compiled null, {errors.Count} error(s):"); + foreach (var e in errors.Take(3)) + Console.WriteLine($" {e}"); + fails++; + } + } + catch (Exception ex) + { + Console.WriteLine($" [{label}] FAIL: {ex.Message}"); + fails++; + } + } + + Console.WriteLine($"[Test T] {(fails == 0 ? "PASS" : "FAIL - see above")}"); + } } diff --git a/KitX Clients/KitX Dashboard b/KitX Clients/KitX Dashboard index 11e7d6e1..bbf4ce23 160000 --- a/KitX Clients/KitX Dashboard +++ b/KitX Clients/KitX Dashboard @@ -1 +1 @@ -Subproject commit 11e7d6e1eff43e89e9562457b74ac2c5d65cd750 +Subproject commit bbf4ce23676d4ef615582a26f36567784ac44f2f diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptExecutor.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptExecutor.cs index 16f58211..518c4732 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptExecutor.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptExecutor.cs @@ -111,14 +111,14 @@ public async Task ExecuteAsync( CFG2CSGenerator.IsDebugMode = _debugger != null; // Full-script assembly compilation - var compiled = _assemblyCompiler.CompileScript(script, _workflowId); + var compiled = _assemblyCompiler.CompileScript(script, _workflowId, out var compileErrors); if (compiled == null) { _stopwatch.Stop(); return new BlockScriptExecutionResult { IsSuccess = false, - ErrorMessage = "Script compilation failed", + ErrorMessage = FormatCompileErrors(compileErrors), ExecutionTimeMs = _stopwatch.ElapsedMilliseconds, Output = _output }; @@ -184,14 +184,14 @@ internal async Task ExecuteFromCFGAsync( { CFG2CSGenerator.IsDebugMode = _debugger != null; - var compiled = _assemblyCompiler.CompileFromCFG(cfg, script, _workflowId); + var compiled = _assemblyCompiler.CompileFromCFG(cfg, script, _workflowId, out var compileErrors); if (compiled == null) { _stopwatch.Stop(); return new BlockScriptExecutionResult { IsSuccess = false, - ErrorMessage = "Script compilation failed", + ErrorMessage = FormatCompileErrors(compileErrors), ExecutionTimeMs = _stopwatch.ElapsedMilliseconds, Output = _output }; @@ -316,4 +316,37 @@ public BlockScriptValidationResult Validate(BlockScript script) return result; } + + /// + /// Formats Roslyn compilation diagnostics into a single multi-line string suitable + /// for display in the workflow editor's output panel. Capped at 10 entries so a flood + /// of cascading errors (e.g. one missing type producing dozens) stays readable. + /// + private static string FormatCompileErrors(IReadOnlyList? errors) + { + if (errors is null || errors.Count == 0) + return "Script compilation failed (no diagnostic details were captured)."; + + const int maxShown = 10; + var sb = new System.Text.StringBuilder(); + sb.Append("Script compilation failed with "); + sb.Append(errors.Count); + sb.Append(" error"); + if (errors.Count != 1) sb.Append('s'); + sb.Append(':'); + sb.AppendLine(); + var shown = Math.Min(maxShown, errors.Count); + for (var i = 0; i < shown; i++) + { + sb.Append(" • "); + sb.AppendLine(errors[i]); + } + if (errors.Count > maxShown) + { + sb.Append(" • …and "); + sb.Append(errors.Count - maxShown); + sb.AppendLine(" more (see Log/ for the full list)."); + } + return sb.ToString().TrimEnd(); + } } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptParser.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptParser.cs index 679ec825..7ad034c1 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptParser.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptParser.cs @@ -37,6 +37,10 @@ public BlockScriptParseResult Parse(string sourceCode) }; } + // Declared outside the try so the catch block can still attach whatever diagnostics + // were collected before the exception. + var diagnostics = new ConversionDiagnostics(); + try { Log.Debug("[BlockScriptParser] Parse called with {LineCount} lines of code", sourceCode.Split('\n').Length); @@ -75,12 +79,13 @@ public BlockScriptParseResult Parse(string sourceCode) { IsSuccess = false, ErrorMessage = validationResult.ErrorMessage, - ErrorLine = recognized.StartLine + validationResult.ErrorLine + ErrorLine = recognized.StartLine + validationResult.ErrorLine, + Diagnostics = diagnostics }; } // Phase 3: Extract statements and create BlockDefinition - var blockDef = _extractor.CreateBlockDefinition(recognized, validationResult); + var blockDef = _extractor.CreateBlockDefinition(recognized, validationResult, diagnostics); // Add to appropriate slot in script switch (recognized.BlockType) @@ -119,7 +124,8 @@ public BlockScriptParseResult Parse(string sourceCode) return new BlockScriptParseResult { IsSuccess = true, - Script = script + Script = script, + Diagnostics = diagnostics }; } catch (Exception ex) @@ -129,7 +135,8 @@ public BlockScriptParseResult Parse(string sourceCode) { IsSuccess = false, ErrorMessage = $"Parse error: {ex.Message}", - ErrorLine = 0 + ErrorLine = 0, + Diagnostics = diagnostics }; } } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockStatementExtractor.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockStatementExtractor.cs index 45cca35d..daf5603e 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockStatementExtractor.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockStatementExtractor.cs @@ -22,9 +22,15 @@ public BlockStatementExtractor(BuiltinFunctionRegistry? functionRegistry = null) _functionRegistry = functionRegistry; } /// - /// Creates a BlockDefinition from a recognized block and its validation result + /// Creates a BlockDefinition from a recognized block and its validation result. /// - public BlockDefinition CreateBlockDefinition(RecognizedBlock recognized, BlockValidationResult validationResult) + /// Collector for user-facing diagnostics recorded during + /// extraction (e.g. unsupported expression forms). May be null when the caller does not + /// want diagnostics surfaced. + public BlockDefinition CreateBlockDefinition( + RecognizedBlock recognized, + BlockValidationResult validationResult, + ConversionDiagnostics? diagnostics = null) { // For ConstBlock/PubVarBlock/MainBlock, use default names if BlockName is empty var blockName = recognized.BlockName; @@ -55,7 +61,10 @@ public BlockDefinition CreateBlockDefinition(RecognizedBlock recognized, BlockVa var root = validationResult.ParsedRoot; if (root == null) { - // Fallback: parse if not available (should not happen in normal flow) + // Backend-bug class: the validator should always provide ParsedRoot. Log and recover + // rather than surface to the user — this is an engine-internal invariant, not a user error. + Log.Error("[BlockStatementExtractor] ParsedRoot was null for block '{BlockName}'; re-parsing as fallback", + blockDef.Name); var syntaxTree = CSharpSyntaxTree.ParseText(recognized.Content); root = syntaxTree.GetRoot(); } @@ -91,7 +100,7 @@ public BlockDefinition CreateBlockDefinition(RecognizedBlock recognized, BlockVa // Extract statements for MainBlock and NamedBlock if (recognized.BlockType == BlockType.MainBlock || recognized.BlockType == BlockType.NamedBlock) { - ExtractStatements(root, blockDef, recognized.BlockType); + ExtractStatements(root, blockDef, recognized.BlockType, diagnostics); } return blockDef; @@ -121,7 +130,9 @@ public void CreateLoopBlocksForBlock(BlockDefinition block, BlockScript script) /// /// Extracts statements from syntax root into block definition /// - private void ExtractStatements(SyntaxNode root, BlockDefinition block, BlockType blockType) + private void ExtractStatements( + SyntaxNode root, BlockDefinition block, BlockType blockType, + ConversionDiagnostics? diagnostics) { var rawContent = root.ToFullString(); Log.Debug("[BlockStatementExtractor] ExtractStatements: Block '{BlockName}' raw content ({Len} chars):\n{RawContent}", @@ -177,11 +188,15 @@ private void ExtractStatements(SyntaxNode root, BlockDefinition block, BlockType if (stmt != null) block.Statements.Add(stmt); else + // Carry the already-parsed invocation so BS2CFGConverter does not + // re-parse the same expression text (eliminates the double parse). block.Statements.Add(new ExpressionStatement { LineNumber = exprStmt.GetLineNumber(), SourceCode = exprText, - Expression = exprText + Expression = exprText, + ParsedInvocation = invoke, + AssignedVariable = null }); } else @@ -190,7 +205,9 @@ private void ExtractStatements(SyntaxNode root, BlockDefinition block, BlockType { LineNumber = exprStmt.GetLineNumber(), SourceCode = exprText, - Expression = exprText + Expression = exprText, + ParsedInvocation = invoke, + AssignedVariable = null }); } } @@ -201,6 +218,9 @@ private void ExtractStatements(SyntaxNode root, BlockDefinition block, BlockType { var methodName = ExprUtils.GetMethodName(assignInvoke); Log.Debug("[BlockStatementExtractor] assignment.Right is InvocationExpressionSyntax, methodName = {MethodName}", methodName); + // The LHS is the assignment target (e.g. "x" in "x = Func(...)"). Captured once + // here so BS2CFGConverter does not re-derive it by re-parsing. + var assignedVar = assignment.Left.ToString(); // Try BuiltinFunctionRegistry for all registered functions if (_functionRegistry != null && _functionRegistry.Get(methodName) is { } funcDef) @@ -213,7 +233,9 @@ private void ExtractStatements(SyntaxNode root, BlockDefinition block, BlockType { LineNumber = exprStmt.GetLineNumber(), SourceCode = exprText, - Expression = exprText + Expression = exprText, + ParsedInvocation = assignInvoke, + AssignedVariable = assignedVar }); } else @@ -223,7 +245,9 @@ private void ExtractStatements(SyntaxNode root, BlockDefinition block, BlockType { LineNumber = exprStmt.GetLineNumber(), SourceCode = exprText, - Expression = exprText + Expression = exprText, + ParsedInvocation = assignInvoke, + AssignedVariable = assignedVar }); } } @@ -251,11 +275,19 @@ private void ExtractStatements(SyntaxNode root, BlockDefinition block, BlockType } else { + // Not an invocation and not an assignment — per BlockScript grammar a statement + // must be a function call, an assignment, or a NextBlock/flow-control directive. + // This is likely a user error; record a warning (non-fatal) so the editor can + // surface it instead of silently dropping the statement downstream. + var lineNum = exprStmt.GetLineNumber(); + diagnostics?.AddWarning("BS_UNSUPPORTED_EXPR", + $"Unsupported expression form '{exprStmt.Expression.GetType().Name}' in {blockType}: {exprText}", + lineNum); Log.Debug("[BlockStatementExtractor] Unhandled expression type in {BlockType}: {Type} = {Expr}", blockType, exprStmt.Expression.GetType().Name, exprText); block.Statements.Add(new ExpressionStatement { - LineNumber = exprStmt.GetLineNumber(), + LineNumber = lineNum, SourceCode = exprText, Expression = exprText }); @@ -273,15 +305,6 @@ private void ExtractStatements(SyntaxNode root, BlockDefinition block, BlockType } } } - - - - private static string GetStringLiteral(ExpressionSyntax expr) - { - if (expr is LiteralExpressionSyntax literal) - return literal.Token.ValueText; - return expr.ToString().Trim('"'); - } } /// diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BuiltinFunctionRegistry.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BuiltinFunctionRegistry.cs index b9c3cf9a..5144f062 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BuiltinFunctionRegistry.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BuiltinFunctionRegistry.cs @@ -69,7 +69,9 @@ public static BuiltinFunctionRegistry Discover(params Assembly[] assemblies) } catch (Exception ex) { - Log.Warning(ex, "[BuiltinFunctionRegistry] Failed to instantiate {Type}", type.Name); + // Engine-internal: a builtin function definition failed to construct. This is a + // backend bug, not a user/workflow-author error — log to Serilog, do not surface. + Log.Error(ex, "[BuiltinFunctionRegistry] Failed to instantiate {Type}", type.Name); } } } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/CSCompiler.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/CSCompiler.cs index f1dbf315..27a685d8 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/CSCompiler.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/CSCompiler.cs @@ -54,7 +54,9 @@ public CSCompiler() /// /// Compiles a into an . - /// Returns null if compilation fails (caller should fall back to CSharpScript). + /// Returns null if compilation fails; Roslyn diagnostics are surfaced via the + /// out compileErrors overload so the caller (e.g. the Dashboard output panel) can + /// show why compilation failed. /// public ICompiledBlockScript? CompileScript(BlockScript script) => CompileScript(script, workflowId: null); @@ -64,7 +66,19 @@ public CSCompiler() /// with optional disk persistence for cross-session reuse. /// public ICompiledBlockScript? CompileScript(BlockScript script, string? workflowId) + => CompileScript(script, workflowId, out _); + + /// + /// Compiles a and reports Roslyn diagnostics on failure. + /// Use this overload when the caller wants to surface WHY compilation failed + /// (e.g. the workflow editor output panel). + /// + public ICompiledBlockScript? CompileScript( + BlockScript script, + string? workflowId, + out IReadOnlyList compileErrors) { + compileErrors = Array.Empty(); var hash = ScriptCompilationBackend.ComputeScriptHash(script); // Step 1: Check in-memory cache @@ -97,9 +111,12 @@ public CSCompiler() script, formattedScript, pubVarTypes, hash); // Phase 3: Compile via CSharpCompilation - var assembly = ScriptCompilationBackend.CompileToAssembly(compilationUnit, hash); + var assembly = ScriptCompilationBackend.CompileToAssembly(compilationUnit, hash, out var errors); if (assembly == null) + { + compileErrors = errors ?? Array.Empty(); return null; + } // Phase 4: Load into collectible ALContext and instantiate var alc = new CollectibleAssemblyLoadContext(hash); @@ -109,6 +126,7 @@ public CSCompiler() if (scriptType == null) { Log.Warning("[CSCompiler] Compiled type not found in assembly"); + compileErrors = new[] { "Compiled type not found in generated assembly (workflow engine bug)." }; alc.Unload(); return null; } @@ -127,7 +145,8 @@ public CSCompiler() } catch (Exception ex) { - Log.Warning(ex, "[CSCompiler] Compilation failed, returning null for fallback"); + Log.Warning(ex, "[CSCompiler] Compilation threw an exception"); + compileErrors = new[] { $"Compilation threw an exception: {ex.Message}" }; return null; } } @@ -139,7 +158,18 @@ public CSCompiler() /// public ICompiledBlockScript? CompileFromCFG( ControlFlowGraph cfg, BlockScript script, string? workflowId) + => CompileFromCFG(cfg, script, workflowId, out _); + + /// + /// CFG-path variant that also reports Roslyn diagnostics on failure. + /// + public ICompiledBlockScript? CompileFromCFG( + ControlFlowGraph cfg, + BlockScript script, + string? workflowId, + out IReadOnlyList compileErrors) { + compileErrors = Array.Empty(); var baseHash = ScriptCompilationBackend.ComputeScriptHash(script); var hash = CFG2CSGenerator.IsDebugMode ? $"debug_{baseHash}" : baseHash; @@ -175,14 +205,23 @@ public CSCompiler() var pubVarTypes = CFG2CSGenerator.InferPubVarTypes(cfg, script.HelperFunctions, context); var compilationUnit = CFG2CSGenerator.GenerateCompilationUnit(script, cfg, pubVarTypes, hash); - var assembly = ScriptCompilationBackend.CompileToAssembly(compilationUnit, hash); - if (assembly == null) return null; + var assembly = ScriptCompilationBackend.CompileToAssembly(compilationUnit, hash, out var errors); + if (assembly == null) + { + compileErrors = errors ?? Array.Empty(); + return null; + } var alc = new CollectibleAssemblyLoadContext(hash); var loadedAssembly = alc.LoadFromStream(assembly); var typeName = $"KitX.Workflow.BlockScripting.Generated.CompiledScript_{hash}"; var scriptType = loadedAssembly.GetType(typeName); - if (scriptType == null) { alc.Unload(); return null; } + if (scriptType == null) + { + compileErrors = new[] { "Compiled type not found in generated assembly (workflow engine bug)." }; + alc.Unload(); + return null; + } var instance = (ICompiledBlockScript)Activator.CreateInstance(scriptType)!; _cache[hash] = new CompiledScriptEntry(instance, alc); @@ -196,6 +235,7 @@ public CSCompiler() catch (Exception ex) { Log.Warning(ex, "[CSCompiler] CompileFromCFG failed"); + compileErrors = new[] { $"Compilation threw an exception: {ex.Message}" }; return null; } } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/ScriptCompilationBackend.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/ScriptCompilationBackend.cs index f97778a8..028238bb 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/ScriptCompilationBackend.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/ScriptCompilationBackend.cs @@ -19,9 +19,17 @@ internal static class ScriptCompilationBackend /// /// The syntax tree to compile. /// Script hash used for naming. + /// When compilation fails, receives the human-readable Roslyn + /// diagnostics (one entry per error, capped at 10). Null/empty on success. Surfacing + /// these lets the Dashboard show WHY a workflow failed to compile instead of just + /// "Script compilation failed". /// A memory stream containing the assembly, or null on failure. - internal static MemoryStream? CompileToAssembly(CompilationUnitSyntax compilationUnit, string hash) + internal static MemoryStream? CompileToAssembly( + CompilationUnitSyntax compilationUnit, + string hash, + out IReadOnlyList? errors) { + errors = null; var normalized = compilationUnit.NormalizeWhitespace(); var sourceText = normalized.ToFullString(); @@ -55,6 +63,9 @@ internal static class ScriptCompilationBackend Log.Warning("[ScriptCompilationBackend] {Diagnostic}", diag); } + // Hand the diagnostics back to the caller so it can surface them in the UI, + // not just in the log file. + errors = diagnostics; return null; } @@ -64,6 +75,14 @@ internal static class ScriptCompilationBackend return assemblyStream; } + /// + /// Backwards-compatible overload that discards diagnostics. Prefer the + /// + /// overload at new call sites. + /// + internal static MemoryStream? CompileToAssembly(CompilationUnitSyntax compilationUnit, string hash) + => CompileToAssembly(compilationUnit, hash, out _); + /// /// Gets the set of s needed for compilation. /// diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/BranchFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/BranchFunction.cs index d8fa3160..3fe5eeac 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/BranchFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/BranchFunction.cs @@ -40,8 +40,8 @@ public class BranchFunction : IBuiltinFunctionDefinition ControlType = FlowControlType.Branch }; if (args.Count >= 1) stmt.ConditionExpression = args[0].Expression.ToString(); - if (args.Count >= 2) stmt.TrueBlockName = GetStringLiteral(args[1].Expression); - if (args.Count >= 3) stmt.FalseBlockName = GetStringLiteral(args[2].Expression); + if (args.Count >= 2) stmt.TrueBlockName = ExprUtils.GetStringLiteralValue(args[1].Expression) ?? string.Empty; + if (args.Count >= 3) stmt.FalseBlockName = ExprUtils.GetStringLiteralValue(args[2].Expression) ?? string.Empty; return stmt; } @@ -90,9 +90,6 @@ public IEnumerable GetOutputArms() => new() { PinName = "True", IsLoopback = false }, new() { PinName = "False", IsLoopback = false } ]; - - private static string GetStringLiteral(ExpressionSyntax expr) => - expr is LiteralExpressionSyntax lit ? lit.Token.ValueText : expr.ToString().Trim('"'); } } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/FlipFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/FlipFunction.cs index a69a5d53..a4da3757 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/FlipFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/FlipFunction.cs @@ -43,8 +43,8 @@ public class FlipFunction : IBuiltinFunctionDefinition SourceCode = exprText ?? invoke.ToFullString(), ControlType = FlowControlType.Branch // Reuse Branch type for cross-block routing }; - if (args.Count >= 1) stmt.TrueBlockName = GetStringLiteral(args[0].Expression); - if (args.Count >= 2) stmt.FalseBlockName = GetStringLiteral(args[1].Expression); + if (args.Count >= 1) stmt.TrueBlockName = ExprUtils.GetStringLiteralValue(args[0].Expression) ?? string.Empty; + if (args.Count >= 2) stmt.FalseBlockName = ExprUtils.GetStringLiteralValue(args[1].Expression) ?? string.Empty; return stmt; } @@ -90,9 +90,6 @@ public IEnumerable GetOutputArms() => new() { PinName = "A", IsLoopback = false }, new() { PinName = "B", IsLoopback = false } ]; - - private static string GetStringLiteral(ExpressionSyntax expr) => - expr is LiteralExpressionSyntax lit ? lit.Token.ValueText : expr.ToString().Trim('"'); } } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/LoopFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/LoopFunction.cs index cbe8497f..54752b99 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/LoopFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/LoopFunction.cs @@ -41,8 +41,8 @@ public class LoopFunction : IBuiltinFunctionDefinition ControlType = FlowControlType.Loop }; if (args.Count >= 1) stmt.ConditionExpression = args[0].Expression.ToString(); - if (args.Count >= 2) stmt.TrueBlockName = GetStringLiteral(args[1].Expression); - if (args.Count >= 3) stmt.FalseBlockName = GetStringLiteral(args[2].Expression); + if (args.Count >= 2) stmt.TrueBlockName = ExprUtils.GetStringLiteralValue(args[1].Expression) ?? string.Empty; + if (args.Count >= 3) stmt.FalseBlockName = ExprUtils.GetStringLiteralValue(args[2].Expression) ?? string.Empty; return stmt; } @@ -94,9 +94,6 @@ public IEnumerable GetOutputArms() => new() { PinName = "LoopBody", IsLoopback = false }, new() { PinName = "LoopEnd", IsLoopback = false } ]; - - private static string GetStringLiteral(ExpressionSyntax expr) => - expr is LiteralExpressionSyntax lit ? lit.Token.ValueText : expr.ToString().Trim('"'); } } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PluginCallFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PluginCallFunction.cs index a0b74035..c2a277ef 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PluginCallFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PluginCallFunction.cs @@ -15,7 +15,14 @@ public class PluginCallFunction : IBuiltinFunctionDefinition public string FunctionName => "PluginCall"; public string DisplayName => "PluginCall"; public bool IsFlowControl => false; - public bool IsNonExtractable => true; + // PluginCall has a return value (OutputPin "Return") and is meant to be used as + // an expression in argument position (e.g. Set("x", PluginCall("P","M",arg))). + // IsNonExtractable=true would make BS2CFGConverter.ExpandExpression keep it inline + // as a raw string instead of expanding it into a temp PubVar + standalone call, + // which then fails to compile (CS0103 'PluginCall' undefined in generated C#). + // false matches PluginCallWithTarget/Get/TryGetDevice — value-producing builtins + // that CAN be nested. Set/Print/Pause stay true (pure side-effects, no return). + public bool IsNonExtractable => false; public CFGStatementKind StatementKind => CFGStatementKind.Expression; public double NodeWidth => 140; public double NodeHeight => 80; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ToLoopCondFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ToLoopCondFunction.cs index 9a8d749f..1e4a8ffc 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ToLoopCondFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ToLoopCondFunction.cs @@ -44,7 +44,7 @@ public class ToLoopCondFunction : IBuiltinFunctionDefinition SourceCode = exprText ?? invoke.ToFullString(), ControlType = FlowControlType.ToLoopCond }; - if (args.Count >= 1) stmt.ToLoopCondReturnTo = GetStringLiteral(args[0].Expression); + if (args.Count >= 1) stmt.ToLoopCondReturnTo = ExprUtils.GetStringLiteralValue(args[0].Expression) ?? string.Empty; return stmt; } @@ -107,9 +107,6 @@ public List EmitStatements(CFGStatement stmt, CSEmitContext ctx public IEnumerable GetOutputArms() => [ new() { PinName = "Exec", IsLoopback = false } ]; - - private static string GetStringLiteral(ExpressionSyntax expr) => - expr is LiteralExpressionSyntax lit ? lit.Token.ValueText : expr.ToString().Trim('"'); } } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/ConversionDiagnostics.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/ConversionDiagnostics.cs new file mode 100644 index 00000000..27a9ebb6 --- /dev/null +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/ConversionDiagnostics.cs @@ -0,0 +1,112 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace KitX.Workflow.Contract.Models; + +/// +/// Severity of a conversion/parse diagnostic. Mirrors the compile-path model so the +/// Dashboard editor output panel can render conversion problems the same way it renders +/// Roslyn compile errors. +/// +public enum ConversionDiagSeverity +{ + /// Blocks conversion from producing correct output; should surface to the user. + Error, + + /// Non-fatal (e.g. dead code per BlockScript §6); surfaced as a warning. + Warning, +} + +/// +/// A single user-facing diagnostic produced during BlockScript parsing or BS↔BP conversion. +/// Stable lets the editor/UI filter and deduplicate. +/// is 1-based, matching . +/// +public sealed record ConversionDiag(ConversionDiagSeverity Severity, string Code, string Message, int? LineNumber = null); + +/// +/// Collects user-facing diagnostics across the parse + conversion pipeline, mirroring the +/// out IReadOnlyList<string> errors channel already used by the Roslyn compile path. +/// Backend-bug-class problems (unexpected null roots, registry instantiation failures, missing +/// nodes during assembly) are NOT collected here — those go to Serilog and stay in backend logs. +/// +public sealed class ConversionDiagnostics +{ + private readonly List _items = new(); + + /// All diagnostics in insertion order. + public IReadOnlyList Items => _items; + + /// True when at least one was recorded. + public bool HasErrors => _items.Any(d => d.Severity == ConversionDiagSeverity.Error); + + /// True when at least one was recorded. + public bool HasWarnings => _items.Any(d => d.Severity == ConversionDiagSeverity.Warning); + + public IEnumerable Errors => _items.Where(d => d.Severity == ConversionDiagSeverity.Error); + public IEnumerable Warnings => _items.Where(d => d.Severity == ConversionDiagSeverity.Warning); + + /// Records a diagnostic. + public void Add(ConversionDiag diag) => _items.Add(diag); + + /// Convenience: record an error diagnostic. + public void AddError(string code, string message, int? lineNumber = null) + => _items.Add(new ConversionDiag(ConversionDiagSeverity.Error, code, message, lineNumber)); + + /// Convenience: record a warning diagnostic. + public void AddWarning(string code, string message, int? lineNumber = null) + => _items.Add(new ConversionDiag(ConversionDiagSeverity.Warning, code, message, lineNumber)); + + /// Appends all diagnostics from another collector (e.g. parse-time into convert-time). + public void AddRange(ConversionDiagnostics? other) + { + if (other == null) return; + _items.AddRange(other._items); + } + + /// + /// Renders the diagnostics as a single multi-line string suitable for the Dashboard editor + /// output panel. Mirrors BlockScriptExecutor.FormatCompileErrors: capped at 10 entries + /// so a cascade stays readable. Returns an empty string when there are no diagnostics. + /// + public string Format() + { + if (_items.Count == 0) return string.Empty; + + const int maxShown = 10; + var sb = new StringBuilder(); + var errCount = _items.Count(d => d.Severity == ConversionDiagSeverity.Error); + var warnCount = _items.Count - errCount; + + if (errCount > 0) + { + sb.Append("Conversion failed with ").Append(errCount).Append(" error"); + if (errCount != 1) sb.Append('s'); + if (warnCount > 0) sb.Append(" and ").Append(warnCount).Append(" warning").Append(warnCount != 1 ? "s" : ""); + sb.Append(':'); + } + else + { + sb.Append("Conversion completed with ").Append(warnCount).Append(" warning").Append(warnCount != 1 ? "s" : "").Append(':'); + } + sb.AppendLine(); + + var shown = Math.Min(maxShown, _items.Count); + for (var i = 0; i < shown; i++) + { + var d = _items[i]; + sb.Append(" • [").Append(d.Severity).Append("] "); + if (d.LineNumber is { } line) sb.Append("Line ").Append(line).Append(": "); + sb.Append(d.Message); + if (!string.IsNullOrEmpty(d.Code)) sb.Append(" (").Append(d.Code).Append(')'); + sb.AppendLine(); + } + if (_items.Count > maxShown) + { + sb.Append(" • …and ").Append(_items.Count - maxShown) + .AppendLine(" more (see Log/ for the full list)."); + } + return sb.ToString().TrimEnd(); + } +} \ No newline at end of file diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/Results/BlockScriptParseResult.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/Results/BlockScriptParseResult.cs index de063295..cc110135 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/Results/BlockScriptParseResult.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/Results/BlockScriptParseResult.cs @@ -24,4 +24,11 @@ public class BlockScriptParseResult /// The parsed script if successful /// public BlockScript? Script { get; set; } + + /// + /// User-facing diagnostics recorded during parsing (e.g. dead-code warnings, unsupported + /// statement forms). Empty when the parse is clean. Carried into the conversion result by + /// BlockScriptToBlueprintConverter so the Dashboard editor can surface them. + /// + public ConversionDiagnostics Diagnostics { get; set; } = new(); } \ No newline at end of file diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/Statements/ExpressionStatement.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/Statements/ExpressionStatement.cs index b2356df4..5c0e7d22 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/Statements/ExpressionStatement.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/Statements/ExpressionStatement.cs @@ -1,3 +1,5 @@ +using Microsoft.CodeAnalysis.CSharp.Syntax; + namespace KitX.Workflow.Contract.Models; /// @@ -9,4 +11,21 @@ public class ExpressionStatement : BlockStatement /// The expression to execute /// public string Expression { get; set; } = string.Empty; + + /// + /// The pre-parsed invocation when this statement was extracted from source by + /// BlockStatementExtractor. Lets BS2CFGConverter skip re-parsing + /// (the double-parse smell). Null when the statement was built + /// programmatically (e.g. by CFG2BSConverter from a CFG); BS2CFGConverter + /// then falls back to parsing . Transient — not preserved across + /// BlockScript text serialization. + /// + public InvocationExpressionSyntax? ParsedInvocation { get; set; } + + /// + /// The assigned variable when came from an assignment + /// statement (e.g. x = Func(...)); null for bare expression statements. Mirrors the + /// assignedVar that ExprUtils.ParseStatement would derive, avoiding re-derivation. + /// + public string? AssignedVariable { get; set; } } \ No newline at end of file diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BS2CFGConverter.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BS2CFGConverter.cs index ed48fe03..987c9219 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BS2CFGConverter.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BS2CFGConverter.cs @@ -68,10 +68,25 @@ public ControlFlowGraph Format(BlockScript script, PipelineContext context) private CFGBlock FormatBlock(BlockDefinition blockDef, PipelineContext context) { var result = new CFGBlock { Name = blockDef.Name, NextBlockName = blockDef.NextBlockName }; + // BlockScript §6: a flow-control statement (Branch/Loop/ToLoopCond/Break) terminates + // the block; any statement after it is unreachable dead code. Track the terminator and + // warn (non-fatal) on subsequent statements instead of silently formatting them. + bool seenTerminator = false; foreach (var stmt in blockDef.Statements) { + if (seenTerminator) + { + context.Diagnostics.AddWarning("BS_DEAD_CODE", + $"Unreachable statement after flow-control in block '{blockDef.Name}' is ignored", + stmt.LineNumber > 0 ? stmt.LineNumber : null); + continue; + } + var formatted = FormatStatement(stmt, blockDef.Name, context); result.Statements.AddRange(formatted); + + if (stmt is FlowControlStatement) + seenTerminator = true; } return result; } @@ -86,6 +101,11 @@ private List FormatStatement(BlockStatement stmt, string blockName case ExpressionStatement expr: return FormatExpressionStatement(expr, blockName, context); default: + // Per BlockScript §4.3, variable declarations are not allowed inside MainBlock/ + // NamedBlock; any other unhandled statement form is a user error, not a silent drop. + context.Diagnostics.AddWarning("BS_UNSUPPORTED_STMT", + $"Unsupported statement kind '{stmt.GetType().Name}' in block '{blockName}' is skipped", + stmt.LineNumber > 0 ? stmt.LineNumber : null); return new(); } } @@ -183,13 +203,41 @@ private List FormatExpressionStatement(ExpressionStatement exprStm var result = new List(); var expression = exprStmt.Expression; - // Try to parse the expression - var parsed = ExprUtils.ParseStatement(expression); - if (parsed == null) return result; + // Prefer the invocation that BlockStatementExtractor already parsed and attached, avoiding + // a second Roslyn parse of the same expression text (the double-parse smell). Fall back to + // parsing Expression when ParsedInvocation is absent (e.g. programmatically-built statements + // produced by CFG2BSConverter from a CFG, which have no source tree attached). + ExpressionSyntax? rightExpr; + string? assignedVar; + if (exprStmt.ParsedInvocation is { } preParsed) + { + rightExpr = preParsed; + assignedVar = exprStmt.AssignedVariable; + } + else + { + var parsed = ExprUtils.ParseStatement(expression); + if (parsed == null) + { + context.Diagnostics.AddError("BS_UNPARSEABLE_STMT", + $"Could not parse statement in block '{blockName}': {expression}", + exprStmt.LineNumber > 0 ? exprStmt.LineNumber : null); + return result; + } - var (rightExpr, assignedVar) = parsed.Value; + var (r, a) = parsed.Value; - if (rightExpr == null) return result; + if (r == null) + { + context.Diagnostics.AddError("BS_UNPARSEABLE_STMT", + $"Statement in block '{blockName}' has no right-hand expression: {expression}", + exprStmt.LineNumber > 0 ? exprStmt.LineNumber : null); + return result; + } + + rightExpr = r; + assignedVar = a; + } // Handle "NextBlock = ..." assignments (should be handled as FlowControl by parser) if (assignedVar != null && assignedVar == "NextBlock") @@ -199,7 +247,13 @@ private List FormatExpressionStatement(ExpressionStatement exprStm if (rightExpr is InvocationExpressionSyntax invoke) { var funcName = ExprUtils.GetMethodName(invoke); - if (string.IsNullOrEmpty(funcName)) return result; + if (string.IsNullOrEmpty(funcName)) + { + context.Diagnostics.AddError("BS_EMPTY_FUNCNAME", + $"Invocation in block '{blockName}' has no resolvable function name: {expression}", + exprStmt.LineNumber > 0 ? exprStmt.LineNumber : null); + return result; + } // Skip flow control functions (handled by FlowControlStatement) if (_functionRegistry != null && _functionRegistry.Get(funcName) is { } fcDef && fcDef.IsFlowControl) diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BlockScriptToBlueprintConverter.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BlockScriptToBlueprintConverter.cs index cfe4adb7..2e989b05 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BlockScriptToBlueprintConverter.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BlockScriptToBlueprintConverter.cs @@ -22,6 +22,13 @@ public class BlockScriptToBlueprintConverter : IBlockScriptToBlueprintConverter /// public PipelineContext? LastContext { get; private set; } + /// + /// User-facing diagnostics from the last conversion (parse-time + convert-time merged). + /// Empty when the conversion was clean. Surface this in the Dashboard editor output panel + /// the same way BlockScriptExecutor.FormatCompileErrors surfaces compile errors. + /// + public ConversionDiagnostics? LastDiagnostics => LastContext?.Diagnostics; + public BlockScriptToBlueprintConverter( IBlockScriptParser parser, INodeRegistry nodeRegistry, @@ -50,7 +57,12 @@ public KitX.Core.Contract.Workflow.Blueprint Convert(string sourceCode, List strategi public KitX.Core.Contract.Workflow.Blueprint Blueprint { get; private set; } = null!; + /// + /// User-facing diagnostics from the last BP→BS conversion. Empty when clean. Surface in the + /// Dashboard editor output panel; backend-bug-class problems stay in Serilog logs. + /// + public ConversionDiagnostics? LastDiagnostics { get; private set; } + public string Convert(KitX.Core.Contract.Workflow.Blueprint blueprint) => ConvertToBlockScript(blueprint).SourceCode; @@ -60,6 +66,7 @@ public BlockScript ConvertToBlockScript(KitX.Core.Contract.Workflow.Blueprint bl // Phase 1: BP → CFG via pipeline var cfg = CFGPipeline.BP2CFG(blueprint, _strategyMap, _builtinMap, _exportHelper, prebuiltBuilder: _cfgBuilder); LastCFG = cfg; + LastDiagnostics = ctx.Diagnostics; Log.Debug("[BlueprintToScript] CFG: {BlockCount} blocks, {EdgeCount} edges", cfg.Blocks.Count, cfg.Blocks.Sum(b => b.Successors.Count)); diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFG2CSGenerator.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFG2CSGenerator.cs index baf6a16a..fc019cf3 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFG2CSGenerator.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFG2CSGenerator.cs @@ -583,8 +583,16 @@ internal static SwitchSectionSyntax GenerateFormattedBlockCase( } else { - rawExpr = ParseExpression( - $"{stmt.FunctionName}({string.Join(", ", stmt.Arguments)})"); + // Unknown bare call (not a registered builtin, helper, or dotted plugin + // method). Emit it as a direct invocation via SyntaxFactory so the Roslyn + // compile step surfaces CS0103 (name not found) to the user, instead of + // round-tripping through string concatenation + reparse. + var bareArgs = stmt.Arguments + .Select(a => Argument(ResolveArgumentExpression(a, pubVarTypes))) + .ToArray(); + rawExpr = InvocationExpression( + IdentifierName(stmt.FunctionName ?? string.Empty), + ArgumentList(SeparatedList(bareArgs))); } caseStatements.AddRange( @@ -694,9 +702,16 @@ internal static InvocationExpressionSyntax BuildGInvoke(string member, params Ex internal static List EmitDefaultStatements(CFGStatement stmt, CSEmitContext ctx) { var name = stmt.FunctionName ?? ""; - var args = string.Join(", ", stmt.Arguments ?? new List()); + var argExprs = (stmt.Arguments ?? new List()) + .Select(a => ResolveArgumentExpression(a, ctx.PubVarTypes)) + .ToArray(); var isBuiltin = FunctionRegistry.AllFunctionNames.Contains(name); - var rawExpr = ParseExpression(isBuiltin ? $"G.{name}({args})" : $"{name}({args})"); + // Build the invocation directly via SyntaxFactory (no string concat + reparse). For + // registered builtins emit G.Name(args); for helpers emit Name(args). An unresolved + // name surfaces as CS0103 at compile time — the same user-visible result as before. + var rawExpr = isBuiltin + ? BuildGInvoke(name, argExprs) + : InvocationExpression(IdentifierName(name), ArgumentList(SeparatedList(argExprs.Select(Argument)))); return BuildValueAssignment(stmt.PubVarTarget, rawExpr, "object", ctx.PubVarTypes); } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/ConversionContext.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/ConversionContext.cs index 6a5229ad..8638622e 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/ConversionContext.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/ConversionContext.cs @@ -44,6 +44,11 @@ internal class ConversionContext /// Maps loop node ID to the block name that contains the Loop statement. /// public Dictionary LoopOwnerBlockNames { get; set; } = new(); + + /// + /// User-facing diagnostics accumulated across BP→CFG→BS phases. + /// + public ConversionDiagnostics Diagnostics { get; set; } = new(); } /// diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/PipelineContext.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/PipelineContext.cs index 27f9ba9c..175e7fae 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/PipelineContext.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/PipelineContext.cs @@ -66,6 +66,12 @@ public class PipelineContext // --- Debug tracking --- public List DebugLog { get; set; } = new(); + + /// + /// User-facing diagnostics accumulated across BS→CFG→BP phases. Backend-bug-class + /// problems go to Serilog instead and are NOT collected here. + /// + public ConversionDiagnostics Diagnostics { get; set; } = new(); } /// diff --git a/KitX.sln b/KitX.sln index efbf79b5..76cf9783 100644 --- a/KitX.sln +++ b/KitX.sln @@ -109,6 +109,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TestPlugin.Winform.Core", " EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TestPlugin.WPF.Core", "KitX SDK\KitX Plugins\TestPlugin.WPF.Core\TestPlugin.WPF.Core.csproj", "{73C9C918-2222-4425-9214-887BED47C77F}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "KitX.AI.Plugin", "KitX SDK\KitX Plugins\OfficialPlugins\KitX.AI.Plugin\KitX.AI.Plugin.csproj", "{1182ADC7-4F4B-473A-9353-5E48BCF0FA77}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "KitX.FloatAssist.Plugin", "KitX SDK\KitX Plugins\OfficialPlugins\KitX.FloatAssist.Plugin\KitX.FloatAssist.Plugin.csproj", "{EB923640-DD83-4723-ABC4-D4DEABB6A840}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Csharpell", "Reference\Csharpell\Csharpell\Csharpell.csproj", "{657F7502-43A9-4FC0-8026-1C8F2D103DBA}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Csharpell.Core", "Reference\Csharpell\Csharpell.Core\Csharpell.Core.csproj", "{C161612B-8AE2-4F1C-A29E-875C411F31D1}" @@ -159,6 +163,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "KitX Workflow", "KitX Workf EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KitX.Workflow", "KitX Clients\KitX Workflow\KitX.Workflow\KitX.Workflow.csproj", "{E4438FD7-74FB-417C-B4F1-FC5C6040F5FC}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NodifyM.Avalonia", "Reference\NodifyM.Avalonia\NodifyM.Avalonia\NodifyM.Avalonia.csproj", "{F2A135DA-A0B5-317D-1E23-DC7BBACF02C9}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -593,6 +599,46 @@ Global {73C9C918-2222-4425-9214-887BED47C77F}.Release|x64.Build.0 = Release|Any CPU {73C9C918-2222-4425-9214-887BED47C77F}.Release|x86.ActiveCfg = Release|Any CPU {73C9C918-2222-4425-9214-887BED47C77F}.Release|x86.Build.0 = Release|Any CPU + {1182ADC7-4F4B-473A-9353-5E48BCF0FA77}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1182ADC7-4F4B-473A-9353-5E48BCF0FA77}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1182ADC7-4F4B-473A-9353-5E48BCF0FA77}.Debug|ARM.ActiveCfg = Debug|Any CPU + {1182ADC7-4F4B-473A-9353-5E48BCF0FA77}.Debug|ARM.Build.0 = Debug|Any CPU + {1182ADC7-4F4B-473A-9353-5E48BCF0FA77}.Debug|ARM64.ActiveCfg = Debug|Any CPU + {1182ADC7-4F4B-473A-9353-5E48BCF0FA77}.Debug|ARM64.Build.0 = Debug|Any CPU + {1182ADC7-4F4B-473A-9353-5E48BCF0FA77}.Debug|x64.ActiveCfg = Debug|Any CPU + {1182ADC7-4F4B-473A-9353-5E48BCF0FA77}.Debug|x64.Build.0 = Debug|Any CPU + {1182ADC7-4F4B-473A-9353-5E48BCF0FA77}.Debug|x86.ActiveCfg = Debug|Any CPU + {1182ADC7-4F4B-473A-9353-5E48BCF0FA77}.Debug|x86.Build.0 = Debug|Any CPU + {1182ADC7-4F4B-473A-9353-5E48BCF0FA77}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1182ADC7-4F4B-473A-9353-5E48BCF0FA77}.Release|Any CPU.Build.0 = Release|Any CPU + {1182ADC7-4F4B-473A-9353-5E48BCF0FA77}.Release|ARM.ActiveCfg = Release|Any CPU + {1182ADC7-4F4B-473A-9353-5E48BCF0FA77}.Release|ARM.Build.0 = Release|Any CPU + {1182ADC7-4F4B-473A-9353-5E48BCF0FA77}.Release|ARM64.ActiveCfg = Release|Any CPU + {1182ADC7-4F4B-473A-9353-5E48BCF0FA77}.Release|ARM64.Build.0 = Release|Any CPU + {1182ADC7-4F4B-473A-9353-5E48BCF0FA77}.Release|x64.ActiveCfg = Release|Any CPU + {1182ADC7-4F4B-473A-9353-5E48BCF0FA77}.Release|x64.Build.0 = Release|Any CPU + {1182ADC7-4F4B-473A-9353-5E48BCF0FA77}.Release|x86.ActiveCfg = Release|Any CPU + {1182ADC7-4F4B-473A-9353-5E48BCF0FA77}.Release|x86.Build.0 = Release|Any CPU + {EB923640-DD83-4723-ABC4-D4DEABB6A840}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EB923640-DD83-4723-ABC4-D4DEABB6A840}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EB923640-DD83-4723-ABC4-D4DEABB6A840}.Debug|ARM.ActiveCfg = Debug|Any CPU + {EB923640-DD83-4723-ABC4-D4DEABB6A840}.Debug|ARM.Build.0 = Debug|Any CPU + {EB923640-DD83-4723-ABC4-D4DEABB6A840}.Debug|ARM64.ActiveCfg = Debug|Any CPU + {EB923640-DD83-4723-ABC4-D4DEABB6A840}.Debug|ARM64.Build.0 = Debug|Any CPU + {EB923640-DD83-4723-ABC4-D4DEABB6A840}.Debug|x64.ActiveCfg = Debug|Any CPU + {EB923640-DD83-4723-ABC4-D4DEABB6A840}.Debug|x64.Build.0 = Debug|Any CPU + {EB923640-DD83-4723-ABC4-D4DEABB6A840}.Debug|x86.ActiveCfg = Debug|Any CPU + {EB923640-DD83-4723-ABC4-D4DEABB6A840}.Debug|x86.Build.0 = Debug|Any CPU + {EB923640-DD83-4723-ABC4-D4DEABB6A840}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EB923640-DD83-4723-ABC4-D4DEABB6A840}.Release|Any CPU.Build.0 = Release|Any CPU + {EB923640-DD83-4723-ABC4-D4DEABB6A840}.Release|ARM.ActiveCfg = Release|Any CPU + {EB923640-DD83-4723-ABC4-D4DEABB6A840}.Release|ARM.Build.0 = Release|Any CPU + {EB923640-DD83-4723-ABC4-D4DEABB6A840}.Release|ARM64.ActiveCfg = Release|Any CPU + {EB923640-DD83-4723-ABC4-D4DEABB6A840}.Release|ARM64.Build.0 = Release|Any CPU + {EB923640-DD83-4723-ABC4-D4DEABB6A840}.Release|x64.ActiveCfg = Release|Any CPU + {EB923640-DD83-4723-ABC4-D4DEABB6A840}.Release|x64.Build.0 = Release|Any CPU + {EB923640-DD83-4723-ABC4-D4DEABB6A840}.Release|x86.ActiveCfg = Release|Any CPU + {EB923640-DD83-4723-ABC4-D4DEABB6A840}.Release|x86.Build.0 = Release|Any CPU {657F7502-43A9-4FC0-8026-1C8F2D103DBA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {657F7502-43A9-4FC0-8026-1C8F2D103DBA}.Debug|Any CPU.Build.0 = Debug|Any CPU {657F7502-43A9-4FC0-8026-1C8F2D103DBA}.Debug|ARM.ActiveCfg = Debug|Any CPU @@ -933,6 +979,26 @@ Global {E4438FD7-74FB-417C-B4F1-FC5C6040F5FC}.Release|x64.Build.0 = Release|Any CPU {E4438FD7-74FB-417C-B4F1-FC5C6040F5FC}.Release|x86.ActiveCfg = Release|Any CPU {E4438FD7-74FB-417C-B4F1-FC5C6040F5FC}.Release|x86.Build.0 = Release|Any CPU + {F2A135DA-A0B5-317D-1E23-DC7BBACF02C9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F2A135DA-A0B5-317D-1E23-DC7BBACF02C9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F2A135DA-A0B5-317D-1E23-DC7BBACF02C9}.Debug|ARM.ActiveCfg = Debug|Any CPU + {F2A135DA-A0B5-317D-1E23-DC7BBACF02C9}.Debug|ARM.Build.0 = Debug|Any CPU + {F2A135DA-A0B5-317D-1E23-DC7BBACF02C9}.Debug|ARM64.ActiveCfg = Debug|Any CPU + {F2A135DA-A0B5-317D-1E23-DC7BBACF02C9}.Debug|ARM64.Build.0 = Debug|Any CPU + {F2A135DA-A0B5-317D-1E23-DC7BBACF02C9}.Debug|x64.ActiveCfg = Debug|Any CPU + {F2A135DA-A0B5-317D-1E23-DC7BBACF02C9}.Debug|x64.Build.0 = Debug|Any CPU + {F2A135DA-A0B5-317D-1E23-DC7BBACF02C9}.Debug|x86.ActiveCfg = Debug|Any CPU + {F2A135DA-A0B5-317D-1E23-DC7BBACF02C9}.Debug|x86.Build.0 = Debug|Any CPU + {F2A135DA-A0B5-317D-1E23-DC7BBACF02C9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F2A135DA-A0B5-317D-1E23-DC7BBACF02C9}.Release|Any CPU.Build.0 = Release|Any CPU + {F2A135DA-A0B5-317D-1E23-DC7BBACF02C9}.Release|ARM.ActiveCfg = Release|Any CPU + {F2A135DA-A0B5-317D-1E23-DC7BBACF02C9}.Release|ARM.Build.0 = Release|Any CPU + {F2A135DA-A0B5-317D-1E23-DC7BBACF02C9}.Release|ARM64.ActiveCfg = Release|Any CPU + {F2A135DA-A0B5-317D-1E23-DC7BBACF02C9}.Release|ARM64.Build.0 = Release|Any CPU + {F2A135DA-A0B5-317D-1E23-DC7BBACF02C9}.Release|x64.ActiveCfg = Release|Any CPU + {F2A135DA-A0B5-317D-1E23-DC7BBACF02C9}.Release|x64.Build.0 = Release|Any CPU + {F2A135DA-A0B5-317D-1E23-DC7BBACF02C9}.Release|x86.ActiveCfg = Release|Any CPU + {F2A135DA-A0B5-317D-1E23-DC7BBACF02C9}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -966,6 +1032,8 @@ Global {CE84BF72-98C0-41A1-828B-1B09FB6C47B3} = {447D9264-1F5D-4E66-801A-C43CA36B8D4F} {21365AC3-9A36-4014-B9D0-A427C17E600D} = {447D9264-1F5D-4E66-801A-C43CA36B8D4F} {73C9C918-2222-4425-9214-887BED47C77F} = {447D9264-1F5D-4E66-801A-C43CA36B8D4F} + {1182ADC7-4F4B-473A-9353-5E48BCF0FA77} = {447D9264-1F5D-4E66-801A-C43CA36B8D4F} + {EB923640-DD83-4723-ABC4-D4DEABB6A840} = {447D9264-1F5D-4E66-801A-C43CA36B8D4F} {657F7502-43A9-4FC0-8026-1C8F2D103DBA} = {F021B1E7-44AC-469B-AB4D-36541902C694} {C161612B-8AE2-4F1C-A29E-875C411F31D1} = {F021B1E7-44AC-469B-AB4D-36541902C694} {A0152BF5-A875-40C0-BD57-D99DDCDB5D4C} = {37E33B90-88B7-4404-9D42-D672A2916E6C} @@ -988,6 +1056,7 @@ Global {3C461B17-B9A4-97E1-9A62-79BC972F7A13} = {C4C2D1CC-294B-4C93-8A64-34D5212AD74E} {5B220184-3539-443A-A12B-4381F4817D13} = {673CF32C-65BF-4EB3-83D3-47FEC77B47A0} {E4438FD7-74FB-417C-B4F1-FC5C6040F5FC} = {5B220184-3539-443A-A12B-4381F4817D13} + {F2A135DA-A0B5-317D-1E23-DC7BBACF02C9} = {F021B1E7-44AC-469B-AB4D-36541902C694} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {09BBC227-F41B-4D10-9E38-0EEE07ED17BC} diff --git a/KitX.slnx b/KitX.slnx index 01a32908..8cee8960 100644 --- a/KitX.slnx +++ b/KitX.slnx @@ -44,6 +44,8 @@ + + From 228cb826318b64c7a280457bfdc0ede46361a312 Mon Sep 17 00:00:00 2001 From: StarInk Date: Fri, 19 Jun 2026 18:47:07 +0200 Subject: [PATCH 090/110] =?UTF-8?q?=F0=9F=92=BE=20=F0=9F=A7=A9=20Feat,=20R?= =?UTF-8?q?efactor(Workflow,=20Contract):=20=E5=B7=A5=E4=BD=9C=E6=B5=81?= =?UTF-8?q?=E6=89=A7=E8=A1=8C=E8=BE=93=E5=87=BA=E9=80=8F=E4=BC=A0=E8=87=B3?= =?UTF-8?q?=E6=B4=BB=E5=8A=A8=E6=97=A5=E5=BF=97=20&=20=E8=BD=AC=E6=8D=A2?= =?UTF-8?q?=E5=99=A8=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. PluginCall 内置形式嵌套展开修复 - PluginCallFunction.IsNonExtractable: true → false - 8 个值产生型内置函数(InstallPlugin/ListPluginNames/RunWorkflow/ CreateWorkflow/ReadTextFile/JsonGetField/GetPluginInfoByName/ListWorks) 同步修正为 false,使其可作为嵌套表达式展开为临时 PubVar - 新增 Test T 验证 Set("x", PluginCall(...)) 嵌套展开 2. BuildPluginCallExpression 双形式支持 - 区分点号形式 Plugin.Method(args) 与内置形式 PluginCall(pluginName, methodName, args...),后者前两个参数为插件名/ 方法名(之前误从 FullFunctionName 提取导致插件名="PluginCall") 3. BS2CFGConverter.BinaryExpression 嵌套展开 - ExpandExpression 增加 BinaryExpressionSyntax 处理,递归展开 "prefix" + Get("var") + "suffix" 中嵌套的内置调用 4. Get 按声明类型返回 - BuildGetInvocation 增加 typeName 参数,按 ConstBlock 变量声明类型 生成 G.Get("name") 而非总是 G.Get - InferPubVarTypes 从 Script.ConstBlock 提取变量类型(BS→CFG→CS 路径 ConstNodes 为空);Get 展开的临时 PubVar 继承被读变量类型 5. 工作流执行输出透传 - WorkflowExecutionResultEventArgs 加 Output 字段 - IWorkflowManagementService 加 RunWorkflowWithDetailsAsync → WorkflowRunResult - WorkflowManagementService/TriggerManager 改用新方法,Output 随事件传出 6. 编译诊断透传(保留诊断透传改动) - ScriptCompilationBackend/CSCompiler/BlockScriptExecutor 把 Roslyn 诊断沿调用链传出,编译失败时 ErrorMessage 含具体错误而非仅 "Script compilation failed" 7. --kcs 测试功能 - 可编译任意 .kcs 文件并报告诊断,无需启动 Dashboard --- .../KcsCompileTest.cs | 88 ++++++++++++++ .../KitX.Core.BluePrint.Test/Program.cs | 11 +- KitX Clients/KitX Dashboard | 2 +- .../CreateWorkflowFunction.cs | 2 +- .../BuiltinFunctions/GetFunction.cs | 6 +- .../GetPluginInfoByNameFunction.cs | 2 +- .../BuiltinFunctions/InstallPluginFunction.cs | 2 +- .../BuiltinFunctions/JsonGetFieldFunction.cs | 2 +- .../ListPluginNamesFunction.cs | 2 +- .../BuiltinFunctions/ListWorkflowsFunction.cs | 2 +- .../BuiltinFunctions/ReadTextFileFunction.cs | 2 +- .../BuiltinFunctions/RunWorkflowFunction.cs | 2 +- .../Conversion/BS2CFGConverter.cs | 16 +++ .../Conversion/CFG2CSGenerator.cs | 109 ++++++++++++++++-- .../KitX.Workflow/TriggerManager.cs | 8 +- .../WorkflowManagementService.cs | 17 ++- .../KitX.Workflow/WorkflowScriptService.cs | 3 + KitX Standard | 2 +- 18 files changed, 246 insertions(+), 32 deletions(-) create mode 100644 KitX Clients/KitX Core/KitX.Core.BluePrint.Test/KcsCompileTest.cs diff --git a/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/KcsCompileTest.cs b/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/KcsCompileTest.cs new file mode 100644 index 00000000..88ef44f3 --- /dev/null +++ b/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/KcsCompileTest.cs @@ -0,0 +1,88 @@ +using System; +using System.IO; +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using KitX.Core.DI; +using KitX.Core.Contract.Workflow; +using KitX.Workflow.BlockScripting; +using KitX.Workflow.Contract; +using KitX.Workflow.Contract.Models; + +namespace KitX.Core.BluePrint.Test; + +/// +/// Compiles a .kcs workflow file end-to-end (parse + BS→CFG→CS→assembly) and reports +/// diagnostics. Invoked via --kcs <path>. Useful for validating real workflow +/// scripts without running the Dashboard. +/// +public partial class Program +{ + public static void RunKcsCompileTest(string kcsPath) + { + Console.WriteLine($"=== KCS Compile Test: {kcsPath} ==="); + + if (!File.Exists(kcsPath)) + { + Console.WriteLine($"FAIL: File not found: {kcsPath}"); + return; + } + + var services = new ServiceCollection(); + services.AddCoreServices(); + var sp = services.BuildServiceProvider(); + var parser = sp.GetRequiredService(); + + KcsFileFormat? kcs; + try + { + kcs = JsonSerializer.Deserialize(File.ReadAllText(kcsPath)); + } + catch (Exception ex) + { + Console.WriteLine($"FAIL: JSON parse error: {ex.Message}"); + return; + } + if (kcs == null || string.IsNullOrEmpty(kcs.BlockScriptSource)) + { + Console.WriteLine("FAIL: Empty BlockScriptSource"); + return; + } + + Console.WriteLine($"Name: {kcs.Name}"); + Console.WriteLine($"Id: {kcs.Id}"); + Console.WriteLine($"Trigger: {kcs.TriggerType}"); + Console.WriteLine($"UseBlockMode: {kcs.UseBlockMode}"); + Console.WriteLine($"Helpers: {(kcs.HelperFunctions?.Count ?? 0)}"); + Console.WriteLine(); + + var sourceCode = kcs.BlockScriptSource; + Console.WriteLine("--- BlockScript source ---"); + Console.WriteLine(sourceCode); + Console.WriteLine("--- end ---\n"); + + // Parse + var pr = parser.Parse(sourceCode); + Console.WriteLine($"Parse: {(pr.IsSuccess ? "OK" : "FAIL")}, err: {pr.ErrorMessage}"); + if (pr.Script == null) + { + Console.WriteLine("FAIL: Script is null after parse"); + return; + } + + // Attach helper functions from the kcs file + if (kcs.HelperFunctions != null) + pr.Script.HelperFunctions = kcs.HelperFunctions; + + // Compile + var compiler = new CSCompiler(); + var result = compiler.CompileScript(pr.Script, workflowId: kcs.Id, out var errors); + Console.WriteLine($"Compiled: {(result == null ? "NULL" : "OK")}, errors: {errors.Count}"); + foreach (var e in errors) + Console.WriteLine(" ERR: " + e); + + if (result != null && errors.Count == 0) + Console.WriteLine("\nRESULT: PASS"); + else + Console.WriteLine("\nRESULT: FAIL"); + } +} \ No newline at end of file diff --git a/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs b/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs index bf518739..664adb0c 100644 --- a/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs +++ b/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs @@ -14,7 +14,7 @@ namespace KitX.Core.BluePrint.Test; -public class Program +public partial class Program { // Test selection: --test A,D,K or --test all (default: all) private static HashSet _selectedTests = new(StringComparer.OrdinalIgnoreCase) { "ALL" }; @@ -24,6 +24,15 @@ public static void Main(string[] args) { ParseArgs(args); + // --kcs : compile a .kcs workflow file and report diagnostics. + // Useful for validating real workflow scripts without running the Dashboard. + var kcsIdx = Array.IndexOf(args, "--kcs"); + if (kcsIdx >= 0 && kcsIdx + 1 < args.Length) + { + RunKcsCompileTest(args[kcsIdx + 1]); + return; + } + Console.WriteLine("=== KitX BlockScript → Blueprint Pipeline Test ===\n"); if (_selectedTests.Contains("ALL")) diff --git a/KitX Clients/KitX Dashboard b/KitX Clients/KitX Dashboard index bbf4ce23..3622125b 160000 --- a/KitX Clients/KitX Dashboard +++ b/KitX Clients/KitX Dashboard @@ -1 +1 @@ -Subproject commit bbf4ce23676d4ef615582a26f36567784ac44f2f +Subproject commit 3622125becc8e412bf19a581a882a6ecc46f068d diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/CreateWorkflowFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/CreateWorkflowFunction.cs index e1bbe466..62273c5c 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/CreateWorkflowFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/CreateWorkflowFunction.cs @@ -12,7 +12,7 @@ public class CreateWorkflowFunction : IBuiltinFunctionDefinition public string FunctionName => "CreateWorkflow"; public string DisplayName => "Create Workflow"; public bool IsFlowControl => false; - public bool IsNonExtractable => true; + public bool IsNonExtractable => false; // Value-producing (has Return pin) — can be nested as an expression public CFGStatementKind StatementKind => CFGStatementKind.Expression; public double NodeWidth => 180; public double NodeHeight => 80; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/GetFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/GetFunction.cs index 7f612b95..40904696 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/GetFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/GetFunction.cs @@ -37,7 +37,11 @@ public class GetFunction : IBuiltinFunctionDefinition public List EmitStatements(CFGStatement stmt, CSEmitContext ctx) { var varName = stmt.Arguments?.Count > 0 ? stmt.Arguments[0].Trim('"') : ""; - return ctx.EmitValueAssignment(stmt.PubVarTarget, CFG2CSGenerator.BuildGetInvocation(varName)); + // Look up the ConstBlock variable's declared type so Get returns the right type. + // Without this, Get always returns object, and passing it to a function expecting + // string/int/bool causes CS1503. Falls back to object if the variable is unknown. + var typeName = ctx.PubVarTypes.GetValueOrDefault(varName, "object"); + return ctx.EmitValueAssignment(stmt.PubVarTarget, CFG2CSGenerator.BuildGetInvocation(varName, typeName)); } public List LowerToCFG( diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/GetPluginInfoByNameFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/GetPluginInfoByNameFunction.cs index a3f51953..18727e72 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/GetPluginInfoByNameFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/GetPluginInfoByNameFunction.cs @@ -14,7 +14,7 @@ public class GetPluginInfoByNameFunction : IBuiltinFunctionDefinition public string FunctionName => "GetPluginInfoByName"; public string DisplayName => "Get Plugin Info"; public bool IsFlowControl => false; - public bool IsNonExtractable => true; + public bool IsNonExtractable => false; // Value-producing (has Return pin) — can be nested as an expression public CFGStatementKind StatementKind => CFGStatementKind.Expression; public double NodeWidth => 160; public double NodeHeight => 60; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/InstallPluginFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/InstallPluginFunction.cs index 61d9efec..9bfd3941 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/InstallPluginFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/InstallPluginFunction.cs @@ -13,7 +13,7 @@ public class InstallPluginFunction : IBuiltinFunctionDefinition public string FunctionName => "InstallPlugin"; public string DisplayName => "Install Plugin"; public bool IsFlowControl => false; - public bool IsNonExtractable => true; + public bool IsNonExtractable => false; // Value-producing (has Return pin) — can be nested as an expression public CFGStatementKind StatementKind => CFGStatementKind.Expression; public double NodeWidth => 140; public double NodeHeight => 60; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/JsonGetFieldFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/JsonGetFieldFunction.cs index 35b187e8..ad44b215 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/JsonGetFieldFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/JsonGetFieldFunction.cs @@ -13,7 +13,7 @@ public class JsonGetFieldFunction : IBuiltinFunctionDefinition public string FunctionName => "JsonGetField"; public string DisplayName => "JSON Get Field"; public bool IsFlowControl => false; - public bool IsNonExtractable => true; + public bool IsNonExtractable => false; // Value-producing (has Return pin) — can be nested as an expression public CFGStatementKind StatementKind => CFGStatementKind.Expression; public double NodeWidth => 160; public double NodeHeight => 80; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ListPluginNamesFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ListPluginNamesFunction.cs index 74dd5297..d51b4826 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ListPluginNamesFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ListPluginNamesFunction.cs @@ -14,7 +14,7 @@ public class ListPluginNamesFunction : IBuiltinFunctionDefinition public string FunctionName => "ListPluginNames"; public string DisplayName => "List Plugin Names"; public bool IsFlowControl => false; - public bool IsNonExtractable => true; + public bool IsNonExtractable => false; // Value-producing (has Return pin) — can be nested as an expression public CFGStatementKind StatementKind => CFGStatementKind.Expression; public double NodeWidth => 140; public double NodeHeight => 60; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ListWorkflowsFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ListWorkflowsFunction.cs index 012ed872..dcfb9624 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ListWorkflowsFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ListWorkflowsFunction.cs @@ -13,7 +13,7 @@ public class ListWorkflowsFunction : IBuiltinFunctionDefinition public string FunctionName => "ListWorkflows"; public string DisplayName => "List Workflows"; public bool IsFlowControl => false; - public bool IsNonExtractable => true; + public bool IsNonExtractable => false; // Value-producing (has Return pin) — can be nested as an expression public CFGStatementKind StatementKind => CFGStatementKind.Expression; public double NodeWidth => 140; public double NodeHeight => 60; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ReadTextFileFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ReadTextFileFunction.cs index 5348877a..26fd62cc 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ReadTextFileFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ReadTextFileFunction.cs @@ -12,7 +12,7 @@ public class ReadTextFileFunction : IBuiltinFunctionDefinition public string FunctionName => "ReadTextFile"; public string DisplayName => "Read Text File"; public bool IsFlowControl => false; - public bool IsNonExtractable => true; + public bool IsNonExtractable => false; // Value-producing (has Return pin) — can be nested as an expression public CFGStatementKind StatementKind => CFGStatementKind.Expression; public double NodeWidth => 140; public double NodeHeight => 60; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/RunWorkflowFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/RunWorkflowFunction.cs index c53ba4f7..c4850af7 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/RunWorkflowFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/RunWorkflowFunction.cs @@ -12,7 +12,7 @@ public class RunWorkflowFunction : IBuiltinFunctionDefinition public string FunctionName => "RunWorkflow"; public string DisplayName => "Run Workflow"; public bool IsFlowControl => false; - public bool IsNonExtractable => true; + public bool IsNonExtractable => false; // Value-producing (has Return pin) — can be nested as an expression public CFGStatementKind StatementKind => CFGStatementKind.Expression; public double NodeWidth => 140; public double NodeHeight => 60; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BS2CFGConverter.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BS2CFGConverter.cs index 987c9219..3f3cb475 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BS2CFGConverter.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BS2CFGConverter.cs @@ -444,6 +444,22 @@ private List LowerAndPostProcess( if (expr is ParenthesizedExpressionSyntax paren) return ExpandExpression(paren.Expression, blockName, context); + // Binary expression (e.g. "prefix" + Get("var") + "suffix"): + // Recurse into both operands so nested calls inside concatenations are + // expanded into temp PubVars. Without this, the default branch below would + // stringify the whole expression verbatim, leaving Get/PluginCall/etc. as + // unresolved identifiers in the generated C# (CS0103). + if (expr is BinaryExpressionSyntax binary) + { + var (leftStmts, leftExpr) = ExpandExpression(binary.Left, blockName, context); + var (rightStmts, rightExpr) = ExpandExpression(binary.Right, blockName, context); + var combined = new List(leftStmts); + combined.AddRange(rightStmts); + var op = binary.OperatorToken.Text; + var rebuilt = $"{leftExpr} {op} {rightExpr}"; + return (combined, rebuilt); + } + // Default: return as-is return (new(), expr.ToString()); } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFG2CSGenerator.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFG2CSGenerator.cs index fc019cf3..96ffa641 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFG2CSGenerator.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFG2CSGenerator.cs @@ -50,10 +50,21 @@ internal static Dictionary InferPubVarTypes( foreach (var name in context.PubVarNames) pubVarTypes[name] = "object"; - // Add ConstBlock variables (accessible as identifiers in expressions) + // Add ConstBlock variables (accessible as identifiers in expressions). + // ConstNodes is populated by BS→BP but NOT by BS→CFG→CS, so also read directly + // from the source script's ConstBlock to cover the compilation-only path. foreach (var kvp in context.ConstNodes) pubVarTypes[kvp.Key] = kvp.Value.ConstType ?? "object"; + if (context.Script.ConstBlock != null) + { + foreach (var variable in context.Script.ConstBlock.Variables) + { + if (!pubVarTypes.ContainsKey(variable.Name)) + pubVarTypes[variable.Name] = variable.Type ?? "object"; + } + } + // First pass: SOURCE types foreach (var block in formattedScript.Blocks) { @@ -65,6 +76,15 @@ internal static Dictionary InferPubVarTypes( { pubVarTypes[stmt.PubVarTarget] = helper.ReturnType; } + // Get("varName") returns the same type as the ConstBlock variable it reads. + // Without this, Get's temp PubVar defaults to "object", causing CS1503 when + // passed to functions expecting typed arguments (e.g. InstallPlugin(string)). + else if (stmt.FunctionName == "Get" && stmt.Arguments.Count > 0) + { + var varName = stmt.Arguments[0].Trim('"'); + if (pubVarTypes.TryGetValue(varName, out var varType) && varType != "object") + pubVarTypes[stmt.PubVarTarget] = varType; + } } } @@ -716,16 +736,49 @@ internal static List EmitDefaultStatements(CFGStatement stmt, C } /// - /// Builds G.Get<object>("varName") expression. + /// Builds G.Get<T>("varName") expression. The type parameter T is looked + /// up from the inferred type map (which includes ConstBlock declarations). Falls back + /// to object if the variable is unknown or untyped. This ensures that a + /// Get("installUrl") where installUrl is declared as string in + /// ConstBlock produces G.Get<string>("installUrl") — so the result can + /// be passed directly to functions expecting string without CS1503. /// internal static InvocationExpressionSyntax BuildGetInvocation(string varName) + => BuildGetInvocation(varName, "object"); + + /// + /// Builds G.Get<T>("varName") with an explicit type argument. + /// + internal static InvocationExpressionSyntax BuildGetInvocation(string varName, string typeName) { + // Map BS type keywords to C# type syntax. "object" → object, "string" → string, etc. + // "dynamic" stays dynamic (it suppresses compile-time type checking). + TypeSyntax typeArg; + if (typeName == "dynamic") + typeArg = IdentifierName("dynamic"); + else + typeArg = PredefinedType(Token(SyntaxKind.ObjectKeyword)) // fallback + .WithKeyword(Token(SyntaxKind.ObjectKeyword)); + + // Use the correct C# keyword for primitive types declared in ConstBlock. + typeArg = typeName switch + { + "string" => PredefinedType(Token(SyntaxKind.StringKeyword)), + "int" => PredefinedType(Token(SyntaxKind.IntKeyword)), + "bool" => PredefinedType(Token(SyntaxKind.BoolKeyword)), + "double" => PredefinedType(Token(SyntaxKind.DoubleKeyword)), + "float" => PredefinedType(Token(SyntaxKind.FloatKeyword)), + "char" => PredefinedType(Token(SyntaxKind.CharKeyword)), + "object" => PredefinedType(Token(SyntaxKind.ObjectKeyword)), + "dynamic" => IdentifierName("dynamic"), + _ => ParseTypeName(typeName) // custom types (unlikely for ConstBlock but safe) + }; + return InvocationExpression( MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, IdentifierName("G"), GenericName(Identifier("Get"), - TypeArgumentList(SeparatedList(new TypeSyntax[] - { PredefinedType(Token(SyntaxKind.ObjectKeyword)) })))), + TypeArgumentList(SeparatedList(new TypeSyntax[] { typeArg })))), ArgumentList(SeparatedList(new[] { Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(varName))) @@ -734,20 +787,52 @@ internal static InvocationExpressionSyntax BuildGetInvocation(string varName) /// /// Builds G.PluginCall("pluginName", "methodName", args...) expression. + /// Handles two forms: + /// - Dotted: Plugin.Method(args) — plugin/method extracted from FullFunctionName. + /// - Builtin: PluginCall(pluginName, methodName, args...) — plugin/method are + /// the first two positional Arguments (matching the runtime signature + /// G.PluginCall(string, string, params object[])). /// internal static InvocationExpressionSyntax BuildPluginCallExpression( CFGStatement stmt, Dictionary pubVarTypes) { - var lastDot = (stmt.FullFunctionName ?? "").LastIndexOf('.'); - var pluginName = lastDot >= 0 ? stmt.FullFunctionName![..lastDot] : stmt.FullFunctionName ?? ""; - var methodName = lastDot >= 0 ? stmt.FullFunctionName![(lastDot + 1)..] : ""; + var fullFn = stmt.FullFunctionName ?? ""; + var lastDot = fullFn.LastIndexOf('.'); - var pluginCallArgs = new List + List pluginCallArgs; + List remainingArgs; + + if (lastDot >= 0) { - Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(pluginName))), - Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(methodName))) - }; - pluginCallArgs.AddRange(stmt.Arguments.Select(a => + // Dotted form: Plugin.Method(args) + var pluginName = fullFn[..lastDot]; + var methodName = fullFn[(lastDot + 1)..]; + pluginCallArgs = new() + { + Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(pluginName))), + Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(methodName))) + }; + remainingArgs = stmt.Arguments ?? new(); + } + else + { + // Builtin form: PluginCall(pluginName, methodName, args...) + // The first two Arguments ARE the plugin/method names (they may be ConstBlock + // variable references like `uiPlugin` or string literals like `"KitX.AI.Plugin"`). + // Resolve them as expressions so variable refs become C# identifiers. + var pluginNameArg = stmt.Arguments.Count > 0 ? stmt.Arguments[0] : "\"\""; + var methodNameArg = stmt.Arguments.Count > 1 ? stmt.Arguments[1] : "\"\""; + pluginCallArgs = new() + { + Argument(ResolveArgumentExpression(pluginNameArg, pubVarTypes)), + Argument(ResolveArgumentExpression(methodNameArg, pubVarTypes)) + }; + remainingArgs = stmt.Arguments.Count > 2 + ? stmt.Arguments.Skip(2).ToList() + : new(); + } + + pluginCallArgs.AddRange(remainingArgs.Select(a => Argument(ResolveArgumentExpression(a, pubVarTypes)))); return InvocationExpression( diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/TriggerManager.cs b/KitX Clients/KitX Workflow/KitX.Workflow/TriggerManager.cs index d4a2e362..3a85c0d2 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/TriggerManager.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/TriggerManager.cs @@ -179,12 +179,14 @@ private void OnPluginMessageReceived(object? sender, PluginMessageReceivedEventA Log.Information("[TriggerManager] Triggering workflow: {WorkflowId}", workflowId); _ = System.Threading.Tasks.Task.Run(async () => { - bool success = await ServiceLocator.GetRequiredService().RunWorkflowAsync(workflowId); + var runResult = await ServiceLocator.GetRequiredService() + .RunWorkflowWithDetailsAsync(workflowId); ServiceLocator.GetRequiredService().Publish( WorkflowEventNames.WorkflowExecutionResult, new WorkflowExecutionResultEventArgs( - workflowId, success, - success ? null : "Workflow execution failed")); + workflowId, runResult.IsSuccess, + runResult.IsSuccess ? null : runResult.ErrorMessage ?? "Workflow execution failed", + runResult.Output)); }); } } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/WorkflowManagementService.cs b/KitX Clients/KitX Workflow/KitX.Workflow/WorkflowManagementService.cs index 41ee3ebb..784aa0ce 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/WorkflowManagementService.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/WorkflowManagementService.cs @@ -53,7 +53,14 @@ public void RemoveWorkflow(string workflowId) /// public async Task RunWorkflowAsync(string workflowId) { - const string location = $"{nameof(WorkflowManagementService)}.{nameof(RunWorkflowAsync)}"; + var result = await RunWorkflowWithDetailsAsync(workflowId); + return result.IsSuccess; + } + + /// + public async Task RunWorkflowWithDetailsAsync(string workflowId) + { + const string location = $"{nameof(WorkflowManagementService)}.{nameof(RunWorkflowWithDetailsAsync)}"; try { @@ -65,7 +72,7 @@ public async Task RunWorkflowAsync(string workflowId) Log.Warning("[{Location}] Workflow data not found in storage for ID: {WorkflowId}. " + "Expected file path: {Path}", location, workflowId, storageService.GetWorkflowFilePath(workflowId)); - return false; + return new WorkflowRunResult(false, "Workflow data not found", null); } Log.Information("[{Location}] Loaded workflow '{Name}' (ID: {Id}), " + @@ -87,7 +94,7 @@ public async Task RunWorkflowAsync(string workflowId) { Log.Warning("[{Location}] Workflow '{Name}' (ID: {Id}) has no executable source code", location, data.Name, workflowId); - return false; + return new WorkflowRunResult(false, "No executable source code", null); } Log.Information("[{Location}] Executing workflow '{Name}' ({SourceLength} chars)...", @@ -113,13 +120,13 @@ public async Task RunWorkflowAsync(string workflowId) location, data.Name, result.ErrorMessage); } - return result.IsSuccess; + return new WorkflowRunResult(result.IsSuccess, result.ErrorMessage, result.Output); } catch (Exception ex) { Log.Error(ex, "[{Location}] Error running workflow {WorkflowId}: {Message}", location, workflowId, ex.Message); - return false; + return new WorkflowRunResult(false, ex.Message, null); } } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/WorkflowScriptService.cs b/KitX Clients/KitX Workflow/KitX.Workflow/WorkflowScriptService.cs index f62d1786..1b9592cd 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/WorkflowScriptService.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/WorkflowScriptService.cs @@ -126,6 +126,9 @@ private WorkflowScriptService() public Task RunWorkflowAsync(string workflowId) => ManagementService.RunWorkflowAsync(workflowId); + public Task RunWorkflowWithDetailsAsync(string workflowId) + => ManagementService.RunWorkflowWithDetailsAsync(workflowId); + public Task StopWorkflowAsync(string workflowId) => ManagementService.StopWorkflowAsync(workflowId); public Task CompileAndPersistWorkflowAsync(string workflowId) => diff --git a/KitX Standard b/KitX Standard index c1671fa9..7d256fca 160000 --- a/KitX Standard +++ b/KitX Standard @@ -1 +1 @@ -Subproject commit c1671fa964ca435501b5d8bc3e81f3d767978a1d +Subproject commit 7d256fca55ecde81fa115dd16c2fce030c01a803 From efb4172b8ce67646812bcbda51f53d0a7d699613 Mon Sep 17 00:00:00 2001 From: StarInk Date: Fri, 19 Jun 2026 23:13:35 +0200 Subject: [PATCH 091/110] =?UTF-8?q?=F0=9F=92=BE=20=F0=9F=A7=A9=20Feat,=20R?= =?UTF-8?q?efactor(Workflow):=20:=20Add=20StringConcat=20function=20and=20?= =?UTF-8?q?enhance=20KCS=20compile=20test=20with=20round-trip=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implemented StringConcat builtin function for concatenating multiple strings. - Created runtime method for StringConcat to handle concatenation logic. - Enhanced KCS compile test to include optional round-trip validation for converter regressions. - Updated Program.cs to support new --roundtrip flag for KCS compile tests. - Improved error handling and diagnostics during round-trip conversion in KcsCompileTest. - Added support for preserving assigned variables in BlockStatementExtractor. - Modified various builtin functions to support output variable assignment. - Enhanced BP2CFGConverter and BS2CFGConverter to handle string concatenation and expression expansion. --- .../KcsCompileTest.cs | 100 ++++++++++++- .../KitX.Core.BluePrint.Test/Program.cs | 138 +++++++++++++++++- KitX Clients/KitX Dashboard | 2 +- .../BlockScripting/BlockStatementExtractor.cs | 6 +- .../CreateWorkflowFunction.cs | 6 +- .../GetPluginInfoByNameFunction.cs | 6 +- .../BuiltinFunctions/InstallPluginFunction.cs | 6 +- .../BuiltinFunctions/JsonGetFieldFunction.cs | 6 +- .../ListPluginNamesFunction.cs | 6 +- .../BuiltinFunctions/ListWorkflowsFunction.cs | 6 +- .../BuiltinFunctions/PluginCallFunction.cs | 56 ++++++- .../BuiltinFunctions/ReadTextFileFunction.cs | 6 +- .../BuiltinFunctions/RunWorkflowFunction.cs | 6 +- .../BuiltinFunctions/StartPluginFunction.cs | 6 +- .../BuiltinFunctions/StopPluginFunction.cs | 6 +- .../BuiltinFunctions/StopWorkflowFunction.cs | 6 +- .../BuiltinFunctions/StringConcatFunction.cs | 99 +++++++++++++ .../BuiltinFunctions/StringConcatRuntime.cs | 21 +++ .../Conversion/BP2CFGConverter.cs | 56 ++++++- .../Conversion/BS2CFGConverter.cs | 100 ++++++++++++- .../Conversion/CFG2CSGenerator.cs | 9 +- .../Conversion/DataEdgeBuilder.cs | 9 +- 22 files changed, 617 insertions(+), 45 deletions(-) create mode 100644 KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/StringConcatFunction.cs create mode 100644 KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/StringConcatRuntime.cs diff --git a/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/KcsCompileTest.cs b/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/KcsCompileTest.cs index 88ef44f3..b6b36d90 100644 --- a/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/KcsCompileTest.cs +++ b/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/KcsCompileTest.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.Linq; using System.Text.Json; using Microsoft.Extensions.DependencyInjection; using KitX.Core.DI; @@ -7,19 +8,25 @@ using KitX.Workflow.BlockScripting; using KitX.Workflow.Contract; using KitX.Workflow.Contract.Models; +using KitX.Workflow.Blueprint; +using KitX.Workflow.CFG; +using KitX.Workflow.Conversion; namespace KitX.Core.BluePrint.Test; /// /// Compiles a .kcs workflow file end-to-end (parse + BS→CFG→CS→assembly) and reports -/// diagnostics. Invoked via --kcs <path>. Useful for validating real workflow -/// scripts without running the Dashboard. +/// diagnostics. Invoked via --kcs <path>. With the additional +/// --roundtrip flag, also runs an optional BS→BP→BS→Compile round-trip to +/// surface converter regressions that a one-way compile would hide. Useful for +/// validating real workflow scripts without running the Dashboard. /// public partial class Program { - public static void RunKcsCompileTest(string kcsPath) + public static void RunKcsCompileTest(string kcsPath, bool withRoundTrip = false) { - Console.WriteLine($"=== KCS Compile Test: {kcsPath} ==="); + Console.WriteLine($"=== KCS Compile Test: {kcsPath} ===" + + (withRoundTrip ? " (with round-trip)" : "")); if (!File.Exists(kcsPath)) { @@ -80,7 +87,90 @@ public static void RunKcsCompileTest(string kcsPath) foreach (var e in errors) Console.WriteLine(" ERR: " + e); - if (result != null && errors.Count == 0) + bool compileOk = result != null && errors.Count == 0; + + // Optional round-trip phase — surfaces converter bugs that a single compile hides. + // Mirrors the BS→BP→BS→Compile pattern from Test V but driven by a real .kcs file. + bool roundTripOk = true; + if (withRoundTrip) + { + Console.WriteLine(); + Console.WriteLine("--- Round-trip phase (BS → BP → BS → Compile) ---"); + try + { + var nodeRegistry = sp.GetRequiredService(); + var layoutService = sp.GetRequiredService(); + var reverseConverter = sp.GetRequiredService(); + var funcRegistry = sp.GetRequiredService(); + var converter = new BlockScriptToBlueprintConverter(parser, nodeRegistry, layoutService, funcRegistry); + + var helpers = kcs.HelperFunctions ?? new System.Collections.Generic.List(); + + // BS → BP + var bp = converter.Convert(sourceCode, helpers); + if (bp == null) + { + Console.WriteLine(" FAIL: BS→BP conversion returned null"); + if (converter.LastDiagnostics != null) + Console.WriteLine($" Diagnostics: {converter.LastDiagnostics.Format()}"); + roundTripOk = false; + } + else + { + Console.WriteLine(" BS→BP: OK"); + + // BP → BS + var bsResult = reverseConverter.Convert(bp); + if (string.IsNullOrEmpty(bsResult)) + { + Console.WriteLine(" FAIL: BP→BS conversion returned empty"); + roundTripOk = false; + } + else + { + Console.WriteLine(" BP→BS: OK"); + Console.WriteLine(" --- Round-tripped BlockScript ---"); + Console.WriteLine(bsResult); + Console.WriteLine(" --- end ---"); + + // Parse + compile the round-tripped source + var pr2 = parser.Parse(bsResult); + if (!pr2.IsSuccess || pr2.Script == null) + { + Console.WriteLine($" FAIL: round-tripped BS parse error: {pr2.ErrorMessage}"); + roundTripOk = false; + } + else + { + pr2.Script.HelperFunctions = helpers; + var compiled2 = new CSCompiler().CompileScript(pr2.Script, workflowId: kcs.Id, out var errors2); + if (compiled2 == null) + { + Console.WriteLine($" FAIL: round-tripped BS compile null, {errors2.Count} error(s)"); + foreach (var e in errors2.Take(10)) Console.WriteLine($" {e}"); + roundTripOk = false; + } + else + { + Console.WriteLine(" Round-tripped BS compile: OK"); + } + } + } + } + } + catch (Exception ex) + { + Console.WriteLine($" FAIL: round-trip exception: {ex.Message}"); + roundTripOk = false; + } + } + + // Final verdict combines the one-way compile and (if requested) the round-trip. + bool overall = compileOk && roundTripOk; + Console.WriteLine(); + if (withRoundTrip) + Console.WriteLine($"RESULT: {(overall ? "PASS" : "FAIL")} (compile={compileOk}, roundtrip={roundTripOk})"); + else if (compileOk) Console.WriteLine("\nRESULT: PASS"); else Console.WriteLine("\nRESULT: FAIL"); diff --git a/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs b/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs index 664adb0c..0522d7f9 100644 --- a/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs +++ b/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs @@ -26,10 +26,14 @@ public static void Main(string[] args) // --kcs : compile a .kcs workflow file and report diagnostics. // Useful for validating real workflow scripts without running the Dashboard. + // --roundtrip (alongside --kcs): additionally run a BS→BP→BS→Compile round-trip + // to surface converter regressions a one-way compile would hide. var kcsIdx = Array.IndexOf(args, "--kcs"); if (kcsIdx >= 0 && kcsIdx + 1 < args.Length) { - RunKcsCompileTest(args[kcsIdx + 1]); + bool withRoundTrip = Array.IndexOf(args, "--roundtrip") >= 0 + || Array.IndexOf(args, "-r") >= 0; + RunKcsCompileTest(args[kcsIdx + 1], withRoundTrip); return; } @@ -248,6 +252,14 @@ public static void Main(string[] args) Console.WriteLine("└──────────────────────────────────────────────┘\n"); RunDiagnosticsTest(converter, helpers, "Test U"); } + + if (ShouldRunTest("V")) + { + Console.WriteLine("\n┌──────────────────────────────────────────────┐"); + Console.WriteLine("│ Test V: StringConcat Round-Trip │"); + Console.WriteLine("└──────────────────────────────────────────────┘\n"); + RunStringConcatRoundTripTest(parser, converter, reverseConverter, new List()); + } } // ────────────────────────────────────────────── @@ -1977,4 +1989,128 @@ private static void RunNestedPluginCallTest(IBlockScriptParser parser) Console.WriteLine($"[Test T] {(fails == 0 ? "PASS" : "FAIL - see above")}"); } + + // Test V: StringConcat round-trip — verifies that: + // 1. BS source with "+" string concatenation compiles to assembly. + // 2. BS→BP→BS round-trip preserves the semantics (StringConcat nodes appear). + // 3. The round-tripped BS also compiles. + // 4. Explicit StringConcat(a, b, c) in BS also compiles and round-trips. + private static void RunStringConcatRoundTripTest( + IBlockScriptParser parser, + BlockScriptToBlueprintConverter converter, + IBlueprintToBlockScriptConverter reverseConverter, + List helpers) + { + Console.WriteLine("[Test V] StringConcat: + expansion, compile, BS↔BP round-trip"); + + int fails = 0; + + // --- Sub-test 1: "+" concatenation compiles --- + var srcPlus = @" +#ConstBlock +string a = ""Hello""; +string b = ""World""; + +#PubVarBlock +dynamic v; + +#MainBlock +v = a + "", "" + b + ""!""; +Print(v); +"; + + // --- Sub-test 2: explicit StringConcat compiles --- + var srcExplicit = @" +#ConstBlock +string a = ""Hello""; +string b = ""World""; + +#PubVarBlock +dynamic v; + +#MainBlock +v = StringConcat(a, "", "", b, ""!""); +Print(v); +"; + + foreach (var (label, src) in new[] { ("+ syntax", srcPlus), ("StringConcat explicit", srcExplicit) }) + { + try + { + // Phase 1: parse + compile + var pr = parser.Parse(src); + if (!pr.IsSuccess || pr.Script == null) + { + Console.WriteLine($" [{label}] FAIL: parse error: {pr.ErrorMessage}"); + fails++; + continue; + } + pr.Script.HelperFunctions = helpers; + + var compiler = new CSCompiler(); + var compiled = compiler.CompileScript(pr.Script, workflowId: null, out var errors); + if (compiled == null) + { + Console.WriteLine($" [{label}] FAIL: compile null, {errors.Count} error(s)"); + foreach (var e in errors.Take(3)) Console.WriteLine($" {e}"); + fails++; + continue; + } + Console.WriteLine($" [{label}] Compile: OK"); + + // Phase 2: BS→BP→BS round-trip + var bpResult = converter.Convert(src, helpers); + if (bpResult == null) + { + Console.WriteLine($" [{label}] FAIL: BS→BP conversion returned null"); + fails++; + continue; + } + + var bsResult = reverseConverter.Convert(bpResult); + if (string.IsNullOrEmpty(bsResult)) + { + Console.WriteLine($" [{label}] FAIL: BP→BS conversion returned empty"); + fails++; + continue; + } + + // Phase 3: round-tripped BS should contain StringConcat + if (!bsResult.Contains("StringConcat")) + { + Console.WriteLine($" [{label}] FAIL: round-tripped BS does not contain 'StringConcat'"); + Console.WriteLine($" Round-tripped source:\n{bsResult}"); + fails++; + continue; + } + + // Phase 4: round-tripped BS compiles + var pr2 = parser.Parse(bsResult); + if (!pr2.IsSuccess || pr2.Script == null) + { + Console.WriteLine($" [{label}] FAIL: round-tripped BS parse error: {pr2.ErrorMessage}"); + fails++; + continue; + } + pr2.Script.HelperFunctions = helpers; + var compiled2 = new CSCompiler().CompileScript(pr2.Script, workflowId: null, out var errors2); + if (compiled2 == null) + { + Console.WriteLine($" [{label}] FAIL: round-tripped BS compile null, {errors2.Count} error(s)"); + foreach (var e in errors2.Take(3)) Console.WriteLine($" {e}"); + fails++; + continue; + } + + Console.WriteLine($" [{label}] Round-trip: OK (StringConcat present, re-compiles)"); + } + catch (Exception ex) + { + Console.WriteLine($" [{label}] FAIL: {ex.Message}"); + fails++; + } + } + + Console.WriteLine($"[Test V] {(fails == 0 ? "PASS" : "FAIL - see above")}"); + } } diff --git a/KitX Clients/KitX Dashboard b/KitX Clients/KitX Dashboard index 3622125b..c000afbb 160000 --- a/KitX Clients/KitX Dashboard +++ b/KitX Clients/KitX Dashboard @@ -1 +1 @@ -Subproject commit 3622125becc8e412bf19a581a882a6ecc46f068d +Subproject commit c000afbbcceecce7aabd907441e2d8d3aff1303d diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockStatementExtractor.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockStatementExtractor.cs index daf5603e..bd399a62 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockStatementExtractor.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockStatementExtractor.cs @@ -264,11 +264,15 @@ private void ExtractStatements( else { Log.Debug("[BlockStatementExtractor] assignment.Right is NOT InvocationExpressionSyntax, type = {Type}", assignment.Right.GetType().Name); + // Preserve AssignedVariable so BS2CFGConverter can handle + // non-invocation RHS (e.g. v = a + b + c where RHS is BinaryExpression). block.Statements.Add(new ExpressionStatement { LineNumber = exprStmt.GetLineNumber(), SourceCode = exprText, - Expression = exprText + Expression = exprText, + AssignedVariable = assignment.Left.ToString(), + ParsedInvocation = assignment.Right as InvocationExpressionSyntax }); } } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/CreateWorkflowFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/CreateWorkflowFunction.cs index 62273c5c..19a46cf3 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/CreateWorkflowFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/CreateWorkflowFunction.cs @@ -41,10 +41,12 @@ public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) { var name = helper.GetInputValue(node, "Name"); var source = helper.GetInputValue(node, "Source"); + var expr = $"{FunctionName}({name}, {source})"; + var pubVar = helper.GetOutputPubVar(node, "Return"); return new ExpressionStatement { - Expression = $"{FunctionName}({name}, {source})", - SourceCode = $"{FunctionName}({name}, {source});", + Expression = expr, + SourceCode = string.IsNullOrEmpty(pubVar) ? $"{expr};" : $"{pubVar} = {expr};", LineNumber = 1 }; } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/GetPluginInfoByNameFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/GetPluginInfoByNameFunction.cs index 18727e72..fd82b552 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/GetPluginInfoByNameFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/GetPluginInfoByNameFunction.cs @@ -43,10 +43,12 @@ public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) var pluginName = node is BuiltinFunctionNode bfn ? bfn.Properties.GetValueOrDefault("PluginName", "") ?? "" : ""; + var expr = $"{FunctionName}(\"{pluginName}\")"; + var pubVar = helper.GetOutputPubVar(node, "Return"); return new ExpressionStatement { - Expression = $"{FunctionName}(\"{pluginName}\")", - SourceCode = $"{FunctionName}(\"{pluginName}\");", + Expression = expr, + SourceCode = string.IsNullOrEmpty(pubVar) ? $"{expr};" : $"{pubVar} = {expr};", LineNumber = 1 }; } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/InstallPluginFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/InstallPluginFunction.cs index 9bfd3941..fcc8149e 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/InstallPluginFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/InstallPluginFunction.cs @@ -35,10 +35,12 @@ public class InstallPluginFunction : IBuiltinFunctionDefinition public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) { var value = helper.GetInputValue(node, "KxpPath"); + var expr = $"{FunctionName}({value})"; + var pubVar = helper.GetOutputPubVar(node, "Return"); return new ExpressionStatement { - Expression = $"{FunctionName}({value})", - SourceCode = $"{FunctionName}({value});", + Expression = expr, + SourceCode = string.IsNullOrEmpty(pubVar) ? $"{expr};" : $"{pubVar} = {expr};", LineNumber = 1 }; } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/JsonGetFieldFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/JsonGetFieldFunction.cs index ad44b215..bff91651 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/JsonGetFieldFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/JsonGetFieldFunction.cs @@ -44,10 +44,12 @@ public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) var fieldPath = node is BuiltinFunctionNode bfn ? bfn.Properties.GetValueOrDefault("FieldPath", "") ?? "" : ""; + var expr = $"{FunctionName}({jsonValue}, \"{fieldPath}\")"; + var pubVar = helper.GetOutputPubVar(node, "Return"); return new ExpressionStatement { - Expression = $"{FunctionName}({jsonValue}, \"{fieldPath}\")", - SourceCode = $"{FunctionName}({jsonValue}, \"{fieldPath}\");", + Expression = expr, + SourceCode = string.IsNullOrEmpty(pubVar) ? $"{expr};" : $"{pubVar} = {expr};", LineNumber = 1 }; } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ListPluginNamesFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ListPluginNamesFunction.cs index d51b4826..bc7a4919 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ListPluginNamesFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ListPluginNamesFunction.cs @@ -34,10 +34,12 @@ public class ListPluginNamesFunction : IBuiltinFunctionDefinition public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) { + var expr = $"{FunctionName}()"; + var pubVar = helper.GetOutputPubVar(node, "Return"); return new ExpressionStatement { - Expression = $"{FunctionName}()", - SourceCode = $"{FunctionName}();", + Expression = expr, + SourceCode = string.IsNullOrEmpty(pubVar) ? $"{expr};" : $"{pubVar} = {expr};", LineNumber = 1 }; } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ListWorkflowsFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ListWorkflowsFunction.cs index dcfb9624..2150dfbd 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ListWorkflowsFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ListWorkflowsFunction.cs @@ -33,10 +33,12 @@ public class ListWorkflowsFunction : IBuiltinFunctionDefinition public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) { + var expr = $"{FunctionName}()"; + var pubVar = helper.GetOutputPubVar(node, "Return"); return new ExpressionStatement { - Expression = $"{FunctionName}()", - SourceCode = $"{FunctionName}();", + Expression = expr, + SourceCode = string.IsNullOrEmpty(pubVar) ? $"{expr};" : $"{pubVar} = {expr};", LineNumber = 1 }; } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PluginCallFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PluginCallFunction.cs index c2a277ef..8931d239 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PluginCallFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PluginCallFunction.cs @@ -4,6 +4,8 @@ using KitX.Workflow.Conversion; using KitX.Workflow.CFG; using KitX.Workflow.BlockScripting; +using KitX.Workflow.Blueprint; +using static KitX.Workflow.BlockScripting.BlockScriptWellKnown.Pins; namespace KitX.Workflow.BuiltinFunctions { @@ -40,12 +42,58 @@ public class PluginCallFunction : IBuiltinFunctionDefinition public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; - public List EmitStatements(CFGStatement stmt, CSEmitContext ctx) - => ctx.EmitValueAssignment(stmt.PubVarTarget, CFG2CSGenerator.BuildPluginCallExpression(stmt, ctx.PubVarTypes)); + public List EmitStatements(CFGStatement stmt, CSEmitContext ctx) + => ctx.EmitValueAssignment(stmt.PubVarTarget, CFG2CSGenerator.BuildPluginCallExpression(stmt, ctx.PubVarTypes)); - public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) => node; + public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) + { + // PluginCall has variable arguments: PluginName, MethodName, plus optional data args. + // The static descriptor declares 2 data pins (PluginName, MethodName); if the + // statement carries more arguments (e.g. PluginCall(ui, "M", data)), add extra + // input pins so DataEdgeBuilder can wire them into the node and the round-trip + // preserves all arguments. + var existingDataPins = node.InputPins.Count(p => p.Type != PinType.Execution); + var needed = stmt.Arguments?.Count ?? 0; + for (int i = existingDataPins; i < needed; i++) + { + node.InputPins.Add(new BlueprintPin + { + Name = $"arg{i}", + Direction = PinDirection.Input, + Type = PinType.Any + }); + } + return node; + } + + public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) + { + // Collect all non-Exec input pin values in pin order → PluginCall(arg0, arg1, ...) + // Reverses the ConfigureNode + DataEdgeBuilder wiring to reconstruct the original + // expression. Handles PubVar assignment when the Return output is consumed. + var argPins = node.InputPins + .Where(p => p.Type != PinType.Execution) + .ToList(); + if (argPins.Count < 2) return null; - public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) => null; + var args = argPins + .Select(p => helper.GetInputValue(node, p.Name)) + .ToList(); + + var expr = $"{FunctionName}({string.Join(", ", args)})"; + + var pubVar = helper.GetOutputPubVar(node, Return); + var sourceCode = !string.IsNullOrEmpty(pubVar) + ? $"{pubVar} = {expr};" + : $"{expr};"; + + return new ExpressionStatement + { + Expression = expr, + SourceCode = sourceCode, + LineNumber = 1 + }; + } public IEnumerable GetOutputArms() => []; } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ReadTextFileFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ReadTextFileFunction.cs index 26fd62cc..fdf657f1 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ReadTextFileFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ReadTextFileFunction.cs @@ -34,10 +34,12 @@ public class ReadTextFileFunction : IBuiltinFunctionDefinition public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) { var value = helper.GetInputValue(node, "Path"); + var expr = $"{FunctionName}({value})"; + var pubVar = helper.GetOutputPubVar(node, "Return"); return new ExpressionStatement { - Expression = $"{FunctionName}({value})", - SourceCode = $"{FunctionName}({value});", + Expression = expr, + SourceCode = string.IsNullOrEmpty(pubVar) ? $"{expr};" : $"{pubVar} = {expr};", LineNumber = 1 }; } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/RunWorkflowFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/RunWorkflowFunction.cs index c4850af7..889f2c3e 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/RunWorkflowFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/RunWorkflowFunction.cs @@ -34,10 +34,12 @@ public class RunWorkflowFunction : IBuiltinFunctionDefinition public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) { var value = helper.GetInputValue(node, "WorkflowId"); + var expr = $"{FunctionName}({value})"; + var pubVar = helper.GetOutputPubVar(node, "Return"); return new ExpressionStatement { - Expression = $"{FunctionName}({value})", - SourceCode = $"{FunctionName}({value});", + Expression = expr, + SourceCode = string.IsNullOrEmpty(pubVar) ? $"{expr};" : $"{pubVar} = {expr};", LineNumber = 1 }; } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/StartPluginFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/StartPluginFunction.cs index 5cfa963b..addca11c 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/StartPluginFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/StartPluginFunction.cs @@ -35,10 +35,12 @@ public class StartPluginFunction : IBuiltinFunctionDefinition public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) { var value = helper.GetInputValue(node, "PluginName"); + var expr = $"{FunctionName}({value})"; + var pubVar = helper.GetOutputPubVar(node, "Return"); return new ExpressionStatement { - Expression = $"{FunctionName}({value})", - SourceCode = $"{FunctionName}({value});", + Expression = expr, + SourceCode = string.IsNullOrEmpty(pubVar) ? $"{expr};" : $"{pubVar} = {expr};", LineNumber = 1 }; } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/StopPluginFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/StopPluginFunction.cs index c0c5ad17..61d8d115 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/StopPluginFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/StopPluginFunction.cs @@ -35,10 +35,12 @@ public class StopPluginFunction : IBuiltinFunctionDefinition public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) { var value = helper.GetInputValue(node, "PluginName"); + var expr = $"{FunctionName}({value})"; + var pubVar = helper.GetOutputPubVar(node, "Return"); return new ExpressionStatement { - Expression = $"{FunctionName}({value})", - SourceCode = $"{FunctionName}({value});", + Expression = expr, + SourceCode = string.IsNullOrEmpty(pubVar) ? $"{expr};" : $"{pubVar} = {expr};", LineNumber = 1 }; } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/StopWorkflowFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/StopWorkflowFunction.cs index 89da297b..9ae34ae8 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/StopWorkflowFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/StopWorkflowFunction.cs @@ -34,10 +34,12 @@ public class StopWorkflowFunction : IBuiltinFunctionDefinition public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) { var value = helper.GetInputValue(node, "WorkflowId"); + var expr = $"{FunctionName}({value})"; + var pubVar = helper.GetOutputPubVar(node, "Return"); return new ExpressionStatement { - Expression = $"{FunctionName}({value})", - SourceCode = $"{FunctionName}({value});", + Expression = expr, + SourceCode = string.IsNullOrEmpty(pubVar) ? $"{expr};" : $"{pubVar} = {expr};", LineNumber = 1 }; } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/StringConcatFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/StringConcatFunction.cs new file mode 100644 index 00000000..333391cc --- /dev/null +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/StringConcatFunction.cs @@ -0,0 +1,99 @@ +using Microsoft.CodeAnalysis.CSharp.Syntax; +using KitX.Core.Contract.Workflow; +using KitX.Workflow.Conversion; +using KitX.Workflow.CFG; +using KitX.Workflow.BlockScripting; + +namespace KitX.Workflow.BuiltinFunctions; + +/// +/// StringConcat builtin function — concatenates N string inputs into one. +/// BlockScript syntax: StringConcat(part1, part2, ...) +/// Initial blueprint node has 2 input pins; the editor auto-expands a new pin when the +/// last one is connected (see BlueprintEditorViewModel.Connect). +/// +public class StringConcatFunction : IBuiltinFunctionDefinition +{ + public string FunctionName => "StringConcat"; + public string DisplayName => "String Concat"; + public bool IsFlowControl => false; + public bool IsNonExtractable => false; // value-producing, can be nested + public CFGStatementKind StatementKind => CFGStatementKind.Expression; + public double NodeWidth => 140; + public double NodeHeight => 70; + + public IReadOnlyList InputPins => [ + new("Exec", PinType.Execution, 20), + new("A", PinType.String, 35), + new("B", PinType.String, 55), + ]; + + public IReadOnlyList OutputPins => [ + new("Exec", PinType.Execution, 20), + new("Result", PinType.String, 40), + ]; + + public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; + + public List EmitStatements(CFGStatement stmt, CSEmitContext ctx) + { + // Generate G.StringConcat(arg1, arg2, ...) — argument count is dynamic. + var args = (stmt.Arguments ?? new List()) + .Select(a => ctx.ResolveArgument(a)) + .ToArray(); + var concatExpr = ctx.GInvoke("StringConcat", args); + return ctx.EmitValueAssignment(stmt.PubVarTarget, concatExpr); + } + + public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) + { + // Dynamically add String input pins to match the argument count. + // The static InputPins declares 2; if the statement has more, append extras. + var existingDataPins = node.InputPins.Count(p => p.Type != PinType.Execution); + var needed = stmt.Arguments?.Count ?? 0; + for (int i = existingDataPins; i < needed; i++) + { + node.InputPins.Add(new BlueprintPin + { + Name = $"Input {i + 1}", + Direction = PinDirection.Input, + Type = PinType.String + }); + } + return node; + } + + public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) + { + // Collect all non-Exec input pin values in order → StringConcat(val1, val2, ...) + var parts = node.InputPins + .Where(p => p.Type != PinType.Execution) + .Select(p => helper.GetInputValue(node, p.Name)) + .ToList(); + if (parts.Count < 2) return null; + + var expr = $"{FunctionName}({string.Join(", ", parts)})"; + + // If the Result output pin is consumed by a downstream data edge, emit as an + // assignment to the PubVar on that edge; otherwise emit as a bare expression. + var pubVar = helper.GetOutputPubVar(node, "Result"); + if (!string.IsNullOrEmpty(pubVar)) + { + return new ExpressionStatement + { + Expression = expr, + SourceCode = $"{pubVar} = {expr};", + LineNumber = 1 + }; + } + + return new ExpressionStatement + { + Expression = expr, + SourceCode = expr + ";", + LineNumber = 1 + }; + } + + public IEnumerable GetOutputArms() => []; +} \ No newline at end of file diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/StringConcatRuntime.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/StringConcatRuntime.cs new file mode 100644 index 00000000..3eaaf842 --- /dev/null +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/StringConcatRuntime.cs @@ -0,0 +1,21 @@ +namespace KitX.Workflow.BlockScripting; + +/// +/// Runtime method for the StringConcat builtin. Separated into its own file to avoid +/// mixing file-scoped and block-scoped namespaces in the descriptor file. +/// +public partial class BlockScriptExecutionGlobals +{ + /// + /// Concatenates all parts into a single string (null → ""). + /// Mirrors C# string.Concat semantics for object[]. + /// + public string StringConcat(params object[] parts) + { + if (parts == null || parts.Length == 0) return ""; + var sb = new System.Text.StringBuilder(parts.Length * 16); + foreach (var p in parts) + sb.Append(p?.ToString() ?? ""); + return sb.ToString(); + } +} \ No newline at end of file diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BP2CFGConverter.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BP2CFGConverter.cs index 141bf1a7..92d0404c 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BP2CFGConverter.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BP2CFGConverter.cs @@ -828,7 +828,19 @@ private static void SetParentLoopReferences(ControlFlowGraph cfg) { if (node is BuiltinFunctionNode bfNode && _builtinFunctionStrategies.TryGetValue(bfNode.FunctionName, out var bfStrategy)) - return bfStrategy.ToStatement(node, _exportHelper); + { + var blockStmt = bfStrategy.ToStatement(node, _exportHelper); + if (blockStmt != null) return blockStmt; + // ToStatement returned null — build a generic ExpressionStatement + // from the node's function name and input pin values instead of + // silently dropping the node (which would leave orphaned PubVar + // references and cause CS0103 in downstream code generation). + // PluginCallFunction used to return null before its ToStatement was + // implemented; this fallback ensures other builtins are safe too. + Log.Debug("[BP2CFGConverter] BuiltinFunction '{FuncName}' ToStatement returned null, " + + "using generic expression fallback", bfNode.FunctionName); + return BuildGenericBuiltinStatement(bfNode, bfNode.FunctionName); + } return null; } default: @@ -987,6 +999,48 @@ private static CFGStatement CreateToLoopCondStatement(string? returnTo) return stmt; } + /// + /// Fallback: builds a generic ExpressionStatement for a BuiltinFunctionNode whose + /// ToStatement strategy returns null. Collects non-exec input pin values in pin order + /// and emits FunctionName(arg1, arg2, ...). Handles PubVar assignment when + /// a data output pin is consumed by downstream connections. + /// + private BlockStatement? BuildGenericBuiltinStatement(BuiltinFunctionNode bfNode, string functionName) + { + var argPins = bfNode.InputPins + .Where(p => p.Type != PinType.Execution) + .ToList(); + + var args = argPins + .Select(p => _exportHelper.GetInputValue(bfNode, p.Name)) + .ToList(); + + var expr = $"{functionName}({string.Join(", ", args)})"; + + // Determine if an output PubVar assignment is needed (Return/Result pin consumed by downstream connections) + string? pubVar = null; + foreach (var pin in bfNode.OutputPins.Where(p => p.Type != PinType.Execution)) + { + var pv = NodeExportHelper.FindOutputPubVar(bfNode, pin.Name, _currentCtx); + if (!string.IsNullOrEmpty(pv)) + { + pubVar = pv; + break; + } + } + + var sourceCode = !string.IsNullOrEmpty(pubVar) + ? $"{pubVar} = {expr};" + : $"{expr};"; + + return new ExpressionStatement + { + Expression = expr, + SourceCode = sourceCode, + LineNumber = 1 + }; + } + // ════════════════════════════════════════════════════════════════════ // Step 3.5: Resolve Branch/Loop Target Block Names // ════════════════════════════════════════════════════════════════════ diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BS2CFGConverter.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BS2CFGConverter.cs index 3f3cb475..eaf3d61d 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BS2CFGConverter.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BS2CFGConverter.cs @@ -243,6 +243,39 @@ private List FormatExpressionStatement(ExpressionStatement exprStm if (assignedVar != null && assignedVar == "NextBlock") return result; + // If RHS is a "+" binary expression, expand it into a StringConcat call. + // ExpandExpression synthesizes a vaaa#### = StringConcat(...) statement; + // if there's an assignment target (v = "a" + "b"), redirect that statement's + // PubVarTarget to v so we get a single clean CFG statement instead of a + // vaaa#### temp + a separate v = vaaa#### assignment. + if (rightExpr is BinaryExpressionSyntax binExpr && binExpr.OperatorToken.Text == "+") + { + var (expStmts, finalExpr) = ExpandExpression(rightExpr, blockName, context); + + if (!string.IsNullOrEmpty(assignedVar) && assignedVar != "_") + { + // Redirect the last expansion statement's PubVarTarget to assignedVar. + // The last statement is the StringConcat synthesis (vaaa#### = StringConcat(...)). + if (expStmts.Count > 0 && !string.IsNullOrEmpty(expStmts[^1].PubVarTarget)) + { + var oldTarget = expStmts[^1].PubVarTarget; + expStmts[^1].PubVarTarget = assignedVar; + // Update OriginalExpression for traceability. + expStmts[^1].OriginalExpression = expStmts[^1].OriginalExpression? + .Replace(oldTarget, assignedVar); + if (!context.PubVarNames.Contains(assignedVar)) + context.PubVarNames.Add(assignedVar); + } + result.AddRange(expStmts); + } + else + { + // No assignment target — standalone expression (rare for +, but handle it). + result.AddRange(expStmts); + } + return result; + } + // If RHS is a function invocation, process it if (rightExpr is InvocationExpressionSyntax invoke) { @@ -444,18 +477,54 @@ private List LowerAndPostProcess( if (expr is ParenthesizedExpressionSyntax paren) return ExpandExpression(paren.Expression, blockName, context); - // Binary expression (e.g. "prefix" + Get("var") + "suffix"): - // Recurse into both operands so nested calls inside concatenations are - // expanded into temp PubVars. Without this, the default branch below would - // stringify the whole expression verbatim, leaving Get/PluginCall/etc. as - // unresolved identifiers in the generated C# (CS0103). + // Binary expression (e.g. "prefix" + Get("var") + "suffix"). + // For the "+" operator, collect all operands (left-associative chaining) and + // synthesize a single StringConcat(...) call so the CFG→BP path produces a + // proper blueprint node instead of an opaque string expression. if (expr is BinaryExpressionSyntax binary) { + var op = binary.OperatorToken.Text; + if (op == "+") + { + // Flatten left-associative + chains: ((a + b) + c) → [a, b, c] + var operands = new List(); + CollectAddOperands(binary, operands); + + // Expand each operand (nested calls → temp PubVars), collect statements. + var allStmts = new List(); + var argExprs = new List(); + foreach (var operand in operands) + { + var (oStmts, oExpr) = ExpandExpression(operand, blockName, context); + allStmts.AddRange(oStmts); + argExprs.Add(oExpr); + } + + // Synthesize: vaaa#### = StringConcat(arg1, arg2, ...) + var pubVarName = ExprUtils.GeneratePubVarName(context.NextPubVarCounter++); + if (!context.PubVarNames.Contains(pubVarName)) + context.PubVarNames.Add(pubVarName); + + allStmts.Add(new CFGStatement + { + BlockName = blockName, + Kind = CFGStatementKind.Assignment, + PubVarTarget = pubVarName, + FunctionName = "StringConcat", + FullFunctionName = "StringConcat", + Arguments = argExprs, + OriginalExpression = $"{pubVarName} = StringConcat({string.Join(", ", argExprs)})", + Fingerprint = ExprUtils.ComputeFingerprint("StringConcat", argExprs) + }); + + return (allStmts, pubVarName); + } + + // Non-+ binary: recurse operands and rebuild as string (defensive fallback). var (leftStmts, leftExpr) = ExpandExpression(binary.Left, blockName, context); var (rightStmts, rightExpr) = ExpandExpression(binary.Right, blockName, context); var combined = new List(leftStmts); combined.AddRange(rightStmts); - var op = binary.OperatorToken.Text; var rebuilt = $"{leftExpr} {op} {rightExpr}"; return (combined, rebuilt); } @@ -464,6 +533,25 @@ private List LowerAndPostProcess( return (new(), expr.ToString()); } + /// + /// Recursively flattens a left-associative chain of "+" binary expressions into a + /// flat list of leaf operands. e.g. ((a + b) + c) → [a, b, c]. + /// Non-+ leaves (literals, identifiers, calls) are collected as-is. + /// + private static void CollectAddOperands(BinaryExpressionSyntax binary, List operands) + { + if (binary.Left is BinaryExpressionSyntax leftBinary + && leftBinary.OperatorToken.Text == "+") + { + CollectAddOperands(leftBinary, operands); + } + else + { + operands.Add(binary.Left); + } + operands.Add(binary.Right); + } + // ────────────────────────────────────────────── // Condition expansion (for Branch/Loop) // ────────────────────────────────────────────── diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFG2CSGenerator.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFG2CSGenerator.cs index 96ffa641..30b76563 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFG2CSGenerator.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFG2CSGenerator.cs @@ -262,7 +262,11 @@ private static List GenerateHelperFunctions(List ParseHelperFunctionBody(string? code) { if (string.IsNullOrWhiteSpace(code)) - return new List { ReturnStatement(LiteralExpression(SyntaxKind.NullLiteralExpression)) }; + // Use default(T) instead of null so value-type return types (bool/int/...) + // don't cause CS0037. The runtime will overwrite this stub if a real Code + // is provided; this is just the empty-body fallback. + return new List { ReturnStatement( + LiteralExpression(SyntaxKind.DefaultLiteralExpression)) }; var wrapper = $"void __wrapper() {{ {code} }}"; var tree = CSharpSyntaxTree.ParseText(wrapper); @@ -283,7 +287,8 @@ internal static List ParseHelperFunctionBody(string? code) } Log.Warning("[CFG2CSGenerator] Failed to parse helper function body, using empty body"); - return new List { ReturnStatement(LiteralExpression(SyntaxKind.NullLiteralExpression)) }; + return new List { ReturnStatement( + LiteralExpression(SyntaxKind.DefaultLiteralExpression)) }; } // ────────────────────────────────────────────── diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/DataEdgeBuilder.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/DataEdgeBuilder.cs index 1c779fa1..7c0c729c 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/DataEdgeBuilder.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/DataEdgeBuilder.cs @@ -51,8 +51,13 @@ private void ProcessStatement(CFGStatement stmt, PipelineContext context) if (funcDef != null) { - // Map arguments to input pins based on the function definition - var nonExecPins = funcDef.InputPins.Where(p => p.Type != PinType.Execution).ToList(); + // Use the target node's actual input pins (may include dynamic pins added by + // ConfigureNode, e.g. StringConcatFunction and PluginCallFunction). The + // descriptor pins are a subset; reading from the node ensures extra arguments + // map to dynamically added pins and survive the BP round-trip. + var nonExecPins = targetNode.InputPins + .Where(p => p.Type != PinType.Execution) + .ToList(); if (stmt.Arguments != null) { From 804d8737b3ca773a0f298f8752e65827a1904ff6 Mon Sep 17 00:00:00 2001 From: StarInk Date: Sat, 20 Jun 2026 01:41:17 +0200 Subject: [PATCH 092/110] =?UTF-8?q?=F0=9F=A7=A9=20Refactor(Workflow):=20?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E5=88=86=E6=94=AF=E6=A8=A1=E5=9E=8B=E4=B8=BA?= =?UTF-8?q?=20List=20=E5=B9=B6=E5=AE=89=E5=85=A8=E5=8C=96=20Nex?= =?UTF-8?q?tBlock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 BranchArm 类型(位于 Contract 层),取代 CFGStatement/FlowControlStatement 的 TrueBlockName/FalseBlockName/ToLoopCondReturnTo 三槽,改为通用 List - 消灭 BP2CFGConverter 的位置映射 hack(normalArms[0]→True/[1]→False), 改为按输出 Pin 名直接写 Arm,N 路分支不再丢失 - 安全化 NextBlock:移除 BlockScriptExecutionGlobals 的 Set/Get 对 "NextBlock" 的特判,新增 internal AdvanceTo,Branch/Loop/Flip/ToLoopCond 改用它 - 适配全部转换器(BS2CFG/CFG2BS/CFG2CS/CFGConditionDuplicator/Executor)与 5 个控制流 builtin 描述符到 Arms 模型 - 新增整数索引 Switch 内置函数:Switch(selector, "default", "b0", "b1", ...), 越界走 default;Arms 布局 [Default, 0, 1, ..., N-1] - 修复 DataEdgeBuilder:ConditionPubVar 从硬编码 PinType.Boolean 改为首个 非 Exec pin,使 Switch 的 Integer selector 正确连接(BS→BP→BS 往返修复) - BlockScriptExecutor.Validate 改为遍历 Arms 校验,统一覆盖 Branch/Loop/Switch Breaking Changes: CFGStatement/FlowControlStatement 字段迁移 (TrueBlockName/FalseBlockName/ToLoopCondReturnTo → Arms),编译期暴露,无静默风险 --- KitX Clients/KitX Dashboard | 2 +- .../BlockScriptExecutionGlobals.cs | 33 +-- .../BlockScripting/BlockScriptExecutor.cs | 36 +--- .../BlockScripting/BlockScriptWellKnown.cs | 3 + .../IBuiltinFunctionDefinition.cs | 20 ++ .../KitX.Workflow/Blueprint/NodeRegistry.cs | 4 +- .../BuiltinFunctions/BranchFunction.cs | 3 +- .../BuiltinFunctions/FlipFunction.cs | 3 +- .../BuiltinFunctions/LoopFunction.cs | 3 +- .../BuiltinFunctions/StringConcatFunction.cs | 7 + .../BuiltinFunctions/SwitchFunction.cs | 200 ++++++++++++++++++ .../BuiltinFunctions/ToLoopCondFunction.cs | 3 +- .../KitX.Workflow/CFG/CFGBlock.cs | 4 +- .../KitX.Workflow/CFG/CFGEdge.cs | 6 + .../KitX.Workflow/CFG/CFGStatement.cs | 57 +++-- .../Contract/Models/FlowControlType.cs | 8 +- .../Models/Statements/FlowControlStatement.cs | 65 +++++- .../Conversion/BP2CFGConverter.cs | 135 +++++++----- .../Conversion/BS2CFGConverter.cs | 12 +- .../Conversion/CFG2BSConverter.cs | 11 +- .../Conversion/CFGConditionDuplicator.cs | 12 +- .../Conversion/DataEdgeBuilder.cs | 6 +- KitX Standard | 2 +- 23 files changed, 490 insertions(+), 145 deletions(-) create mode 100644 KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/SwitchFunction.cs diff --git a/KitX Clients/KitX Dashboard b/KitX Clients/KitX Dashboard index c000afbb..bc23747b 160000 --- a/KitX Clients/KitX Dashboard +++ b/KitX Clients/KitX Dashboard @@ -1 +1 @@ -Subproject commit c000afbbcceecce7aabd907441e2d8d3aff1303d +Subproject commit bc23747b4c55310129c9cc9390c42da869923add diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptExecutionGlobals.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptExecutionGlobals.cs index ea8a46f4..5c6e47ad 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptExecutionGlobals.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptExecutionGlobals.cs @@ -20,10 +20,25 @@ public partial class BlockScriptExecutionGlobals // ─── 内置属性 ─────────────────────────────────────────────── /// - /// NextBlock 内置变量 - 设置后执行器会跳转到指定块 + /// NextBlock 内置变量 - 设置后执行器会跳转到指定块。 + /// 这是控制流的唯一载体。普通 Set/Get 不再识别 "NextBlock" 这个名字, + /// 因此用户脚本无法通过变量赋值劫持控制流、绕过 Loop/Break 语义或 CFG 校验。 + /// 仅两类调用方可写此属性:(1) 生成的 RunAsync 主干(G.NextBlock = ...)—— + /// 它是可信基础设施,完全由 CFG2CSGenerator 按已校验的 CFG 产出;(2) 受信任的 flow + /// 函数(Branch/Loop/Switch/Flip/ToLoopCond),应优先通过 写入。 /// public string? NextBlock { get; set; } + /// + /// 受信任的控制流改写入口。内置 flow 函数应通过此方法设置下一个块, + /// 而非直接写 ,以保持单一改写路径。 + /// + internal string? AdvanceTo(string? blockName) + { + NextBlock = blockName; + return NextBlock; + } + /// /// Number of blocks executed so far in the current run. /// @@ -47,11 +62,11 @@ public BlockScriptExecutionGlobals(BlockScopeManager scopeManager, List /// /// Gets a variable value dynamically (CSharpScript path). + /// "NextBlock" is no longer a recognised variable name — control flow is only mutated + /// via by trusted flow functions. /// public dynamic Get(string name) { - if (name == "NextBlock") - return NextBlock!; if (_variables.TryGetValue(name, out var value)) return value!; return _scopeManager.ResolveVariable(name)!; @@ -62,8 +77,6 @@ public dynamic Get(string name) /// public T? Get(string name) { - if (name == "NextBlock") - return (T?)(object?)NextBlock; if (_variables.TryGetValue(name, out var value)) return (T?)value; return (T?)_scopeManager.ResolveVariable(name); @@ -74,11 +87,6 @@ public dynamic Get(string name) /// public void Set(string name, object? value) { - if (name == "NextBlock") - { - NextBlock = value as string; - return; - } _variables[name] = value; _scopeManager.SetVariable(name, value, global: false); Debugger?.UpdateVariableSnapshot(GetAllVariables()); @@ -89,11 +97,6 @@ public void Set(string name, object? value) /// public void SetGlobalVariable(string name, object? value) { - if (name == "NextBlock") - { - NextBlock = value as string; - return; - } _variables[name] = value; _scopeManager.SetVariable(name, value, global: true); Debugger?.UpdateVariableSnapshot(GetAllVariables()); diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptExecutor.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptExecutor.cs index 518c4732..df5f94cd 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptExecutor.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptExecutor.cs @@ -273,38 +273,14 @@ public BlockScriptValidationResult Validate(BlockScript script) { if (statement is FlowControlStatement flow) { - if (flow.ControlType == FlowControlType.Branch) + // Validate every arm target exists. Works uniformly for Branch (True/False), + // Loop (LoopBody/LoopEnd), ToLoopCond (Exec loopback) and Switch (Default/0/1/...). + foreach (var arm in flow.Arms) { - if (!string.IsNullOrEmpty(flow.TrueBlockName) && - !allBlockNames.Contains(flow.TrueBlockName)) + if (!string.IsNullOrEmpty(arm.TargetBlockName) && + !allBlockNames.Contains(arm.TargetBlockName)) { - result.AddError($"Block '{flow.TrueBlockName}' referenced in Branch at line {flow.LineNumber} does not exist"); - } - if (!string.IsNullOrEmpty(flow.FalseBlockName) && - !allBlockNames.Contains(flow.FalseBlockName)) - { - result.AddError($"Block '{flow.FalseBlockName}' referenced in Branch at line {flow.LineNumber} does not exist"); - } - } - else if (flow.ControlType == FlowControlType.Loop) - { - if (!string.IsNullOrEmpty(flow.TrueBlockName) && - !allBlockNames.Contains(flow.TrueBlockName)) - { - result.AddError($"Block '{flow.TrueBlockName}' referenced in Loop at line {flow.LineNumber} does not exist"); - } - if (!string.IsNullOrEmpty(flow.FalseBlockName) && - !allBlockNames.Contains(flow.FalseBlockName)) - { - result.AddError($"Block '{flow.FalseBlockName}' referenced in Loop at line {flow.LineNumber} does not exist"); - } - } - else if (flow.ControlType == FlowControlType.ToLoopCond) - { - if (!string.IsNullOrEmpty(flow.ToLoopCondReturnTo) && - !allBlockNames.Contains(flow.ToLoopCondReturnTo)) - { - result.AddError($"Block '{flow.ToLoopCondReturnTo}' referenced in ToLoopCond at line {flow.LineNumber} does not exist"); + result.AddError($"Block '{arm.TargetBlockName}' referenced in {flow.ControlType} (arm '{arm.PinName}') at line {flow.LineNumber} does not exist"); } } } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptWellKnown.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptWellKnown.cs index 58934b25..9d00b79d 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptWellKnown.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptWellKnown.cs @@ -31,6 +31,8 @@ public static class Pins public const string False = "False"; public const string LoopBody = "LoopBody"; public const string LoopEnd = "LoopEnd"; + public const string Default = "Default"; + public const string Selector = "Selector"; } /// Built-in flow control and action function names @@ -38,6 +40,7 @@ public static class Functions { public const string Branch = "Branch"; public const string Loop = "Loop"; + public const string Switch = "Switch"; public const string ToLoopCond = "ToLoopCond"; public const string Break = "Break"; public const string Print = "Print"; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/IBuiltinFunctionDefinition.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/IBuiltinFunctionDefinition.cs index 61e627ab..7cd12406 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/IBuiltinFunctionDefinition.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/IBuiltinFunctionDefinition.cs @@ -67,6 +67,26 @@ public interface IBuiltinFunctionDefinition /// 输出引脚描述 IReadOnlyList OutputPins { get; } + /// + /// 输入侧变长端口配置。非 null 时,蓝图编辑器在该组最后一个输入端口被连接后, + /// 自动追加一个 类型的新输入端口。 + /// 默认 null(非变长)。StringConcat 覆写为字符串变长输入。 + /// + VariadicPinSpec? InputVariadic => null; + + /// + /// 输出侧变长端口配置。非 null 时,蓝图编辑器在该组最后一个输出端口被连接后, + /// 自动追加一个新输出端口。默认 null(非变长)。Switch 覆写为执行流变长输出。 + /// + VariadicPinSpec? OutputVariadic => null; + + /// + /// 按本语句的实际情况返回输出 Pin 描述符。默认返回固定 (旧行为)。 + /// 变长输出节点(如 Switch,其输出 arm 数随语句而变)覆写此方法,按 + /// 数量动态生成 [Default, 0, 1, ..., N-1],使 BS→BP 导入时端口数与 arm 数匹配。 + /// + IReadOnlyList GetOutputPinsFor(CFGStatement stmt) => OutputPins; + // ─── 解析(BlockScript → AST)───────────────── /// diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Blueprint/NodeRegistry.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Blueprint/NodeRegistry.cs index d60363c5..0f46d8b8 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Blueprint/NodeRegistry.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Blueprint/NodeRegistry.cs @@ -89,7 +89,9 @@ public BlueprintNode CreateBuiltinFunctionNode(string functionName) var descriptor = new NodeDescriptor( def.NodeWidth, def.NodeHeight, def.InputPins, def.OutputPins, - def.DisplayName + def.DisplayName, + def.InputVariadic, + def.OutputVariadic ); node.SetDescriptor(descriptor); diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/BranchFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/BranchFunction.cs index 3fe5eeac..b3eceb0f 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/BranchFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/BranchFunction.cs @@ -102,8 +102,7 @@ public partial class BlockScriptExecutionGlobals /// public string? Branch(bool condition, string trueBlock, string falseBlock) { - NextBlock = condition ? trueBlock : falseBlock; - return NextBlock; + return AdvanceTo(condition ? trueBlock : falseBlock); } } } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/FlipFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/FlipFunction.cs index a4da3757..6dc4fe1d 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/FlipFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/FlipFunction.cs @@ -108,8 +108,7 @@ public partial class BlockScriptExecutionGlobals public string? Flip(string outputA, string outputB) { _flipCounter++; - NextBlock = (_flipCounter % 2 == 1) ? outputA : outputB; - return NextBlock; + return AdvanceTo((_flipCounter % 2 == 1) ? outputA : outputB); } /// diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/LoopFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/LoopFunction.cs index 54752b99..3fa0b990 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/LoopFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/LoopFunction.cs @@ -106,8 +106,7 @@ public partial class BlockScriptExecutionGlobals /// public string? Loop(bool condition, string trueBlock, string falseBlock) { - NextBlock = condition ? trueBlock : falseBlock; - return NextBlock; + return AdvanceTo(condition ? trueBlock : falseBlock); } } } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/StringConcatFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/StringConcatFunction.cs index 333391cc..72436260 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/StringConcatFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/StringConcatFunction.cs @@ -33,6 +33,13 @@ public class StringConcatFunction : IBuiltinFunctionDefinition new("Result", PinType.String, 40), ]; + /// + /// Input-side variadic growth: when the last String input is connected, the editor + /// auto-appends a new "Input {N}" String pin. Declared here (on the descriptor) so the + /// editor's generic variadic logic handles it instead of the former StringConcat name match. + /// + public VariadicPinSpec? InputVariadic => new("Input ", 3, PinType.String); + public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; public List EmitStatements(CFGStatement stmt, CSEmitContext ctx) diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/SwitchFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/SwitchFunction.cs new file mode 100644 index 00000000..8b870b13 --- /dev/null +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/SwitchFunction.cs @@ -0,0 +1,200 @@ +using Microsoft.CodeAnalysis.CSharp.Syntax; +using KitX.Core.Contract.Workflow; +using KitX.Workflow.Conversion; +using KitX.Workflow.CFG; +using KitX.Workflow.BlockScripting; + +namespace KitX.Workflow.BuiltinFunctions +{ + using static KitX.Workflow.BlockScripting.BlockScriptWellKnown; + /// + /// Switch 内置函数 — 整数索引 N 路分支控制流。 + /// BlockScript 语法:NextBlock = Switch(selector, "defaultBlock", "b0", "b1", ...) + /// + /// 语义:selector 为整数。命中(0 ≤ selector < N)时走第 selector 个分支块; + /// 越界走 default 块(arg[0])。 + /// + /// + /// Arms 布局:[Default, 0, 1, ..., N-1] —— 第一个是 default,其余按索引顺序。 + /// 蓝图节点初始输出端口为 [Default, 0];编辑器在 0 被连接后自动追加 1、2、...( + /// 见 )。 + /// + /// + public class SwitchFunction : IBuiltinFunctionDefinition + { + private const double PinGap = 20.0; + + public string FunctionName => "Switch"; + public string DisplayName => "Switch"; + public bool IsFlowControl => true; + public bool IsNonExtractable => false; + public bool IsBlockTerminator => true; + public CFGStatementKind StatementKind => CFGStatementKind.Switch; + public double NodeWidth => 120; + public double NodeHeight => 80; + + public IReadOnlyList InputPins => [ + new(Pins.Exec, PinType.Execution, 20), + new(Pins.Selector, PinType.Integer, 40) + ]; + + /// 固定初始模板:[Default, 0]。编辑器按 动态追加。 + public IReadOnlyList OutputPins => [ + new(Pins.Default, PinType.Execution, 20), + new("0", PinType.Execution, 40) + ]; + /// 输出侧变长:0 被连接后追加 "1"、"2"、...(Execution 类型)。 + public VariadicPinSpec? OutputVariadic => new(string.Empty, 1, PinType.Execution); + + /// + /// BS 解析:从 NextBlock = Switch(selector, "default", "b0", "b1", ...) 提取。 + /// arg[0]=default,arg[1..N]=分支块。Arms = [Default, 0, 1, ..., N-1]。 + /// + public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) + { + var args = invoke.ArgumentList.Arguments; + var stmt = new FlowControlStatement + { + LineNumber = lineNumber, + SourceCode = exprText ?? invoke.ToFullString(), + ControlType = FlowControlType.Switch + }; + + if (args.Count >= 1) + stmt.ConditionExpression = args[0].Expression.ToString(); + + // arg[1] = default block; arg[2..N] = branch blocks b0, b1, ... + if (args.Count >= 2) + { + var defaultBlock = ExprUtils.GetStringLiteralValue(args[1].Expression) ?? string.Empty; + stmt.Arms.Add(new BranchArm { PinName = Pins.Default, TargetBlockName = defaultBlock }); + } + else + { + stmt.Arms.Add(new BranchArm { PinName = Pins.Default, TargetBlockName = string.Empty }); + } + + for (int i = 2; i < args.Count; i++) + { + var block = ExprUtils.GetStringLiteralValue(args[i].Expression) ?? string.Empty; + stmt.Arms.Add(new BranchArm { PinName = (i - 2).ToString(), TargetBlockName = block }); + } + + return stmt; + } + + public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) + { + // Ensure the node's output execution pins match the statement's arm count. + // The descriptor's fixed OutputPins is [Default, 0]; append 1, 2, ... up to Arms.Count. + var execOuts = node.OutputPins.Where(p => p.Type == PinType.Execution).ToList(); + // Keep existing pins; append missing ones for arms beyond the base set. + for (int i = execOuts.Count; i < stmt.Arms.Count; i++) + { + node.OutputPins.Add(new BlueprintPin + { + Name = (i - 1).ToString(), // arms[1] → "0", arms[2] → "1", ... + Direction = PinDirection.Output, + Type = PinType.Execution + }); + } + return node; + } + + /// + /// BS→BP 导入:按 arm 数动态生成输出 Pin 描述符 [Default, 0, 1, ..., N-1]。 + /// 覆写默认实现(返回固定 OutputPins)以匹配变长 arm。 + /// + public IReadOnlyList GetOutputPinsFor(CFGStatement stmt) + { + var pins = new List(); + for (int i = 0; i < stmt.Arms.Count; i++) + { + var arm = stmt.Arms[i]; + pins.Add(new PinDescriptor(arm.PinName, PinType.Execution, 20 + i * PinGap)); + } + return pins; + } + + public void OnNodeCreated(BlueprintNode node, CFGStatement stmt, PipelineContext context) + { + // Build deferred edges: one arm per output pin (Default/0/1/...). + if (stmt.Arms.Count == 0) return; + + var arms = stmt.Arms + .Where(a => !string.IsNullOrEmpty(a.TargetBlockName)) + .Select(a => (a.PinName, a.TargetBlockName)) + .ToList(); + + context.DeferredEdges.Add(new DeferredControlFlowEdge + { + SourceStatementId = stmt.StatementId, + Arms = arms, + }); + } + + public List EmitStatements(CFGStatement stmt, CSEmitContext ctx) + { + // G.NextBlock = G.Switch(selector, "default", "b0", "b1", ...); break; + var selExpr = !string.IsNullOrEmpty(stmt.ConditionPubVar) + ? ctx.ResolveArgument(stmt.ConditionPubVar) + : ctx.Parse(stmt.ConditionExpression ?? "0"); + + // EmitNextBlockAssignment(member, params args) — prepend selector then all arm blocks. + var args = new List { selExpr }; + args.AddRange(stmt.Arms.Select(a => (ExpressionSyntax)ctx.Literal(a.TargetBlockName ?? ""))); + return ctx.EmitNextBlockAssignment("Switch", args.ToArray()); + } + + public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) + { + var selector = helper.GetInputValue(node, Pins.Selector); + var stmt = new FlowControlStatement + { + ControlType = FlowControlType.Switch, + ConditionExpression = selector, + LineNumber = 1 + }; + + // Collect arms from the node's output execution pins (Default, 0, 1, ...). + foreach (var pin in node.OutputPins.Where(p => p.Type == PinType.Execution)) + { + var target = ResolveArmTarget(node, pin, helper); + stmt.Arms.Add(new BranchArm { PinName = pin.Name, TargetBlockName = target ?? string.Empty }); + } + + stmt.RegenerateSourceCode(); + return stmt; + } + + /// + /// Traces an output execution pin's connection to find the target block name. + /// + private static string? ResolveArmTarget(BlueprintNode node, BlueprintPin pin, INodeExportHelper helper) + { + var conn = helper.Blueprint.Connections.FirstOrDefault(c => c.SourcePinId == pin.Id); + if (conn == null) return null; + var targetNode = helper.Blueprint.GetNodeById(conn.TargetNodeId); + if (targetNode == null) return null; + var scope = helper.Blueprint.BlockScopes.FirstOrDefault(s => s.NodeIds.Contains(targetNode.Id)); + return scope?.Name; + } + + public IEnumerable GetOutputArms() => []; // variadic; arms come from the node's actual pins + } +} + +namespace KitX.Workflow.BlockScripting +{ + public partial class BlockScriptExecutionGlobals + { + /// + /// Switch — N-way dispatch by integer index. selector out of range → defaultBlock. + /// + public string? Switch(int selector, string defaultBlock, params string[] blocks) + { + var target = (selector < 0 || selector >= blocks.Length) ? defaultBlock : blocks[selector]; + return AdvanceTo(target); + } + } +} diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ToLoopCondFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ToLoopCondFunction.cs index 1e4a8ffc..cfa0ade8 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ToLoopCondFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ToLoopCondFunction.cs @@ -122,8 +122,7 @@ public partial class BlockScriptExecutionGlobals /// public string? ToLoopCond(string parentBlockName) { - NextBlock = parentBlockName; - return NextBlock; + return AdvanceTo(parentBlockName); } } } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/CFG/CFGBlock.cs b/KitX Clients/KitX Workflow/KitX.Workflow/CFG/CFGBlock.cs index b9e243f0..72263e1a 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/CFG/CFGBlock.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/CFG/CFGBlock.cs @@ -74,9 +74,9 @@ public class CFGBlock /// /// Whether this block ends with a control flow statement - /// (Branch, Loop, ToLoopCond, Break, Return). + /// (Branch, Loop, Switch, ToLoopCond, Break, Return). /// public bool EndsWithControlFlow => Statements.Count > 0 && Statements[^1] is { Kind: CFGStatementKind.Branch or CFGStatementKind.Loop - or CFGStatementKind.ToLoopCond or CFGStatementKind.Break }; + or CFGStatementKind.Switch or CFGStatementKind.ToLoopCond or CFGStatementKind.Break }; } \ No newline at end of file diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/CFG/CFGEdge.cs b/KitX Clients/KitX Workflow/KitX.Workflow/CFG/CFGEdge.cs index f3462421..8a99bdbe 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/CFG/CFGEdge.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/CFG/CFGEdge.cs @@ -47,6 +47,12 @@ public enum CFGEdgeType /// Break from the current loop — exits to the loop's exit block. /// Break, + + /// + /// Switch arm taken by integer selector. carries the arm + /// index ("Default" or "0".."N-1"). + /// + Switch, } /// diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/CFG/CFGStatement.cs b/KitX Clients/KitX Workflow/KitX.Workflow/CFG/CFGStatement.cs index 989e20b4..200eefa6 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/CFG/CFGStatement.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/CFG/CFGStatement.cs @@ -1,5 +1,7 @@ namespace KitX.Workflow.CFG; +using KitX.Core.Contract.Workflow; + /// /// Kinds of statements in the CFG. Unified statement kind replacing the former Pipeline.FormattedStatementKind. /// @@ -29,6 +31,9 @@ public enum CFGStatementKind /// Loop(condition, loopBody, afterLoop) Loop, + /// Switch(selector, defaultBlock, b0, b1, ...) — N-way dispatch by integer index + Switch, + /// ToLoopCond("parentBlock") ToLoopCond, @@ -111,8 +116,8 @@ public class CFGStatement // --- For flow control --- /// - /// The condition expression for Branch/Loop statements. - /// May be a PubVar name or a complex expression. + /// The condition expression for Branch/Loop/Switch statements. + /// May be a PubVar name or a complex expression. For Switch this is the integer selector. /// public string? ConditionExpression { get; set; } @@ -122,19 +127,41 @@ public class CFGStatement public string? ConditionPubVar { get; set; } /// - /// Target block name when Branch/Loop condition is true. - /// - public string? TrueBlockName { get; set; } - - /// - /// Target block name when Branch/Loop condition is false. - /// - public string? FalseBlockName { get; set; } - - /// - /// For ToLoopCond: the name of the loop condition block to return to. - /// - public string? ToLoopCondReturnTo { get; set; } + /// Outgoing arms of this control-flow statement. Generalised model replacing the former + /// fixed TrueBlockName/FalseBlockName/ToLoopCondReturnTo triple. + /// See for the per-arm layout of each control-flow kind. + /// + public List Arms { get; set; } = []; + + /// Convenience accessor: the true-branch / loop-body target (Arms[0]). + public string? TrueBlockName + { + get => Arms.Count > 0 ? Arms[0].TargetBlockName : null; + set => SetArm(0, "True", value); + } + + /// Convenience accessor: the false-branch / loop-exit target (Arms[1]). + public string? FalseBlockName + { + get => Arms.Count > 1 ? Arms[1].TargetBlockName : null; + set => SetArm(1, "False", value); + } + + /// Convenience accessor: the ToLoopCond loopback target (Arms[0], IsLoopback=true). + public string? ToLoopCondReturnTo + { + get => Arms.Count > 0 ? Arms[0].TargetBlockName : null; + set => SetArm(0, "Exec", value ?? string.Empty, isLoopback: true); + } + + private void SetArm(int index, string pinName, string? value, bool isLoopback = false) + { + while (Arms.Count <= index) + Arms.Add(new BranchArm()); + Arms[index].PinName = pinName; + Arms[index].TargetBlockName = value ?? string.Empty; + Arms[index].IsLoopback = isLoopback; + } // --- Metadata --- /// diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/FlowControlType.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/FlowControlType.cs index 88a6bc32..e4600d30 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/FlowControlType.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/FlowControlType.cs @@ -28,5 +28,11 @@ public enum FlowControlType /// /// To loop condition - marks the end of a loop body and returns to loop condition /// - ToLoopCond + ToLoopCond, + + /// + /// Switch to one of N blocks by integer index (default on out-of-range). + /// Syntax: Switch(selector, "defaultBlock", "b0", "b1", ...) + /// + Switch } \ No newline at end of file diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/Statements/FlowControlStatement.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/Statements/FlowControlStatement.cs index 45d6e354..a959b854 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/Statements/FlowControlStatement.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/Statements/FlowControlStatement.cs @@ -1,3 +1,5 @@ +using KitX.Core.Contract.Workflow; + namespace KitX.Workflow.Contract.Models; /// @@ -16,24 +18,57 @@ public class FlowControlStatement : BlockStatement public string ConditionExpression { get; set; } = string.Empty; /// - /// Target block name when condition is true (for Branch/Loop) + /// Outgoing arms of this control-flow statement. Replaces the former fixed + /// TrueBlockName/FalseBlockName/ToLoopCondReturnTo triple. + /// + /// Branch: ["True", "False"] + /// Loop: ["LoopBody", "LoopEnd"] + /// ToLoopCond: ["Exec" with =true] + /// Switch: ["Default", "0", "1", ... , "N-1"] + /// + /// + public List Arms { get; set; } = []; + + /// + /// Convenience: the true-branch target (Arms[0].TargetBlockName for Branch/Loop). + /// Kept for readability at call sites that conceptually deal with a two-way branch. /// - public string TrueBlockName { get; set; } = string.Empty; + public string TrueBlockName + { + get => Arms.Count > 0 ? Arms[0].TargetBlockName : string.Empty; + set => SetArm(0, "True", value); + } /// - /// Target block name when condition is false (for Branch/Loop) - /// For Loop: this is the loop exit block + /// Convenience: the false-branch target (Arms[1].TargetBlockName for Branch/Loop). /// - public string FalseBlockName { get; set; } = string.Empty; + public string FalseBlockName + { + get => Arms.Count > 1 ? Arms[1].TargetBlockName : string.Empty; + set => SetArm(1, "False", value); + } /// - /// For ToLoopCond: the block name containing the Loop statement to return to + /// Convenience: the loopback target for ToLoopCond (Arms[0].TargetBlockName). /// - public string? ToLoopCondReturnTo { get; set; } + public string? ToLoopCondReturnTo + { + get => Arms.Count > 0 ? Arms[0].TargetBlockName : null; + set => SetArm(0, "Exec", value ?? string.Empty, isLoopback: true); + } + + private void SetArm(int index, string pinName, string value, bool isLoopback = false) + { + while (Arms.Count <= index) + Arms.Add(new BranchArm()); + Arms[index].PinName = pinName; + Arms[index].TargetBlockName = value; + Arms[index].IsLoopback = isLoopback; + } /// /// Regenerates SourceCode from current field values. - /// Call after updating TrueBlockName/FalseBlockName/etc. to keep SourceCode in sync. + /// Call after updating Arms/ConditionExpression/etc. to keep SourceCode in sync. /// public void RegenerateSourceCode() { @@ -41,11 +76,21 @@ public void RegenerateSourceCode() { FlowControlType.Branch => $"NextBlock = Branch({ConditionExpression}, \"{TrueBlockName}\", \"{FalseBlockName}\");", FlowControlType.Loop => $"NextBlock = Loop({ConditionExpression}, \"{TrueBlockName}\", \"{FalseBlockName}\");", - FlowControlType.ToLoopCond => ToLoopCondReturnTo != null + FlowControlType.ToLoopCond => !string.IsNullOrEmpty(ToLoopCondReturnTo) ? $"NextBlock = ToLoopCond(\"{ToLoopCondReturnTo}\");" : "ToLoopCond();", + FlowControlType.Switch => RegenerateSwitchSource(), FlowControlType.Break => "Break();", _ => SourceCode }; } -} \ No newline at end of file + + private string RegenerateSwitchSource() + { + // Arms layout: [Default, 0, 1, ..., N-1] + if (Arms.Count == 0) return "Switch();"; + var defaultBlock = Arms[0].TargetBlockName; + var blocks = Arms.Skip(1).Select(a => $"\"{a.TargetBlockName}\""); + return $"NextBlock = Switch({ConditionExpression}, \"{defaultBlock}\", {string.Join(", ", blocks)});"; + } +} diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BP2CFGConverter.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BP2CFGConverter.cs index 92d0404c..22698a7a 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BP2CFGConverter.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BP2CFGConverter.cs @@ -478,46 +478,54 @@ private void ProcessControlFlowSubGraph( if (arms == null) return; - foreach (var arm in arms) - { - var pin = cfNode.OutputPins.FirstOrDefault(p => p.Name == arm.PinName); - if (pin == null) continue; + // For variadic output nodes (e.g. Switch), the descriptor's declared arms are a fixed + // base set, but the node's actual output execution pins may be more. Iterate the real + // pins and key each arm by pin name so N arms survive without positional loss. + var loopbackPinNames = arms.Where(a => a.IsLoopback).Select(a => a.PinName).ToHashSet(); + foreach (var pin in cfNode.OutputPins.Where(p => p.Type == PinType.Execution)) + { var conn = execConns.FirstOrDefault(c => c.SourcePinId == pin.Id); if (conn == null) continue; var targetNode = blueprint.GetNodeById(conn.TargetNodeId); if (targetNode == null) continue; + var isLoopback = loopbackPinNames.Contains(pin.Name); var blockName = $"Block_{blockCounter++}"; var block = new CFGBlock { Name = blockName, - Type = arm.IsLoopback ? CFGBlockType.LoopBody : CFGBlockType.Basic, - ParentLoopBlockName = arm.IsLoopback ? FindContainingBlockName(cfg, cfNode.Id) : null + Type = isLoopback ? CFGBlockType.LoopBody : CFGBlockType.Basic, + ParentLoopBlockName = isLoopback ? FindContainingBlockName(cfg, cfNode.Id) : null }; cfg.Blocks.Add(block); - if (arm.IsLoopback) + if (isLoopback) loopOwnerBlockNames[cfNode.Id] = blockName; WalkNode(targetNode, block, cfg, nodeById, reachableNodeIds, execConns, blueprint, new HashSet(), pendingControlFlowNodes: new(), loopNodes, loopOwnerBlockNames, ref blockCounter, - loopbackTargetId: arm.IsLoopback ? cfNode.Id : null); + loopbackTargetId: isLoopback ? cfNode.Id : null); - // Update the CFG statement with the resolved target block name + // Record the resolved target as an arm keyed by the output pin name. + // This generalises the former positional True/False assignment so that + // Branch(True/False), Loop(LoopBody/LoopEnd) and Switch(Default/0/1/...) + // all preserve their identity. var cfStmt = FindBranchStatement(cfg, cfNode.Id); if (cfStmt != null) { - // Map arm pin name to TrueBlockName/FalseBlockName - // First non-loopback arm → TrueBlockName, second → FalseBlockName - var normalArms = arms.Where(a => !a.IsLoopback).ToList(); - var armIndex = normalArms.IndexOf(arm); - if (armIndex == 0) - cfStmt.TrueBlockName = blockName; - else if (armIndex == 1) - cfStmt.FalseBlockName = blockName; + var existing = cfStmt.Arms.FirstOrDefault(a => a.PinName == pin.Name); + if (existing != null) + existing.TargetBlockName = blockName; + else + cfStmt.Arms.Add(new BranchArm + { + PinName = pin.Name, + TargetBlockName = blockName, + IsLoopback = isLoopback + }); cfStmt.OriginalExpression = RegenerateBranchSource(cfStmt); } @@ -545,19 +553,17 @@ private void BuildEdgesFromTopology( && _builtinFunctionStrategies.TryGetValue(lastStmt.FunctionName, out var builtinStrat) && builtinStrat.IsControlFlow) { - var arms = builtinStrat.GetOutputArms(null!); - foreach (var arm in arms) + // Edges are derived directly from the statement's resolved Arms, so N-way + // Switch (Default/0/1/...) and variadic shapes survive without positional loss. + foreach (var arm in lastStmt.Arms) { - var targetBlock = arm.IsLoopback ? lastStmt.ToLoopCondReturnTo - : (IsFirstNormalArm(arms, arm) ? lastStmt.TrueBlockName : lastStmt.FalseBlockName); - - if (string.IsNullOrEmpty(targetBlock)) continue; + if (string.IsNullOrEmpty(arm.TargetBlockName)) continue; block.Successors.Add(new CFGEdge { FromBlockName = block.Name, - ToBlockName = targetBlock, - Type = GetEdgeType(arm), + ToBlockName = arm.TargetBlockName, + Type = GetEdgeType(lastStmt.Kind, arm), PinName = arm.PinName }); } @@ -910,6 +916,7 @@ private CFGStatement ConvertBlockStatementToCfgStatement(BlockStatement blockStm { FlowControlType.Branch => CFGStatementKind.Branch, FlowControlType.Loop => CFGStatementKind.Loop, + FlowControlType.Switch => CFGStatementKind.Switch, FlowControlType.ToLoopCond => CFGStatementKind.ToLoopCond, FlowControlType.Break => CFGStatementKind.Break, _ => CFGStatementKind.Unknown @@ -918,14 +925,19 @@ private CFGStatement ConvertBlockStatementToCfgStatement(BlockStatement blockStm { FlowControlType.Branch => "Branch", FlowControlType.Loop => "Loop", + FlowControlType.Switch => "Switch", FlowControlType.ToLoopCond => "ToLoopCond", FlowControlType.Break => "Break", _ => null }; cfgStmt.ConditionExpression = flow.ConditionExpression; - cfgStmt.TrueBlockName = flow.TrueBlockName; - cfgStmt.FalseBlockName = flow.FalseBlockName; - cfgStmt.ToLoopCondReturnTo = flow.ToLoopCondReturnTo; + // Copy the full arm list so N-way Switch and any variadic shape survive. + cfgStmt.Arms = flow.Arms.Select(a => new BranchArm + { + PinName = a.PinName, + TargetBlockName = a.TargetBlockName, + IsLoopback = a.IsLoopback + }).ToList(); cfgStmt.ConditionPubVar = flow.ConditionExpression?.Trim(); break; @@ -1046,10 +1058,12 @@ private static CFGStatement CreateToLoopCondStatement(string? returnTo) // ════════════════════════════════════════════════════════════════════ /// - /// After blocks are built, resolves the TrueBlockName/FalseBlockName of - /// Branch and Loop statements from Blueprint execution connections. + /// After blocks are built, resolves the outgoing arms () + /// of Branch / Loop / Switch statements from Blueprint execution connections. /// Strategy-generated statements don't know their target block names; /// we derive them from the node's output pins → connections → target node → containing block. + /// Each output execution pin maps to one arm keyed by the pin's , + /// so N-way Switch arms (Default/0/1/...) are preserved without positional loss. /// private static void ResolveControlFlowTargets( ControlFlowGraph cfg, @@ -1061,10 +1075,10 @@ private static void ResolveControlFlowTargets( { foreach (var stmt in block.Statements) { - if (stmt.Kind is not (CFGStatementKind.Branch or CFGStatementKind.Loop)) - continue; - if (!string.IsNullOrEmpty(stmt.TrueBlockName) && !string.IsNullOrEmpty(stmt.FalseBlockName)) + if (stmt.Kind is not (CFGStatementKind.Branch or CFGStatementKind.Loop + or CFGStatementKind.Switch)) continue; + if (stmt.Arms.Count > 0) continue; if (!nodeById.TryGetValue(stmt.StatementId, out var node)) continue; @@ -1079,6 +1093,9 @@ private static void ResolveControlFlowTargetsForNode( ControlFlowGraph cfg, KitX.Core.Contract.Workflow.Blueprint blueprint, List execConns) { + // Map each output execution pin → (pinName, targetBlock), appending one arm per pin. + // Pin name becomes the arm key, so Branch(True/False), Loop(LoopBody/LoopEnd) and + // Switch(Default/0/1/...) all preserve their identity with no positional loss. foreach (var pin in cfNode.OutputPins.Where(p => p.Type == PinType.Execution)) { var conn = execConns.FirstOrDefault(c => c.SourcePinId == pin.Id); @@ -1087,14 +1104,14 @@ private static void ResolveControlFlowTargetsForNode( var targetBlock = FindBlockContainingNode(cfg, conn.TargetNodeId, blueprint); if (targetBlock == null) continue; - // Assign first unset target - if (string.IsNullOrEmpty(stmt.TrueBlockName)) - stmt.TrueBlockName = targetBlock; - else if (string.IsNullOrEmpty(stmt.FalseBlockName)) - stmt.FalseBlockName = targetBlock; + stmt.Arms.Add(new BranchArm + { + PinName = pin.Name, + TargetBlockName = targetBlock + }); } - if (!string.IsNullOrEmpty(stmt.TrueBlockName) || !string.IsNullOrEmpty(stmt.FalseBlockName)) + if (stmt.Arms.Count > 0) stmt.OriginalExpression = RegenerateBranchSource(stmt); } @@ -1145,25 +1162,43 @@ private static void ResolveControlFlowTargetsForNode( return null; } - private static string RegenerateBranchSource(CFGStatement branchStmt) + private static string RegenerateBranchSource(CFGStatement cfStmt) { - return branchStmt.Kind == CFGStatementKind.Loop - ? $"NextBlock = Loop({branchStmt.ConditionExpression}, \"{branchStmt.TrueBlockName}\", \"{branchStmt.FalseBlockName}\");" - : $"NextBlock = Branch({branchStmt.ConditionExpression}, \"{branchStmt.TrueBlockName}\", \"{branchStmt.FalseBlockName}\");"; + return cfStmt.Kind switch + { + CFGStatementKind.Loop => $"NextBlock = Loop({cfStmt.ConditionExpression}, \"{cfStmt.TrueBlockName}\", \"{cfStmt.FalseBlockName}\");", + CFGStatementKind.Switch => RegenerateSwitchSource(cfStmt), + _ => $"NextBlock = Branch({cfStmt.ConditionExpression}, \"{cfStmt.TrueBlockName}\", \"{cfStmt.FalseBlockName}\");" + }; } - private static bool IsFirstNormalArm(IEnumerable arms, OutputArmDescriptor target) + /// + /// Regenerates Switch source from its arms. Arms layout: [Default, 0, 1, ..., N-1]. + /// + private static string RegenerateSwitchSource(CFGStatement cfStmt) { - var normalArms = arms.Where(a => !a.IsLoopback).ToList(); - return normalArms.Count > 0 && normalArms[0].PinName == target.PinName; + if (cfStmt.Arms.Count == 0) return $"NextBlock = Switch({cfStmt.ConditionExpression}, \"\");"; + var defaultBlock = cfStmt.Arms[0].TargetBlockName; + var blocks = cfStmt.Arms.Skip(1).Select(a => $"\"{a.TargetBlockName}\""); + return $"NextBlock = Switch({cfStmt.ConditionExpression}, \"{defaultBlock}\", {string.Join(", ", blocks)});"; } - private static CFGEdgeType GetEdgeType(OutputArmDescriptor arm) + private static CFGEdgeType GetEdgeType(CFGStatementKind kind, BranchArm arm) { if (arm.IsLoopback) return CFGEdgeType.LoopbackToCondition; - // For normal arms: first → BranchTrue/LoopBody, second → BranchFalse/LoopEnd - // This is a simplified heuristic; more sophisticated logic can be added if needed - return CFGEdgeType.BranchTrue; + + // Derive edge semantics from the pin name so the mapping is data-driven rather than + // positional. Handles Branch (True/False), Loop (LoopBody/LoopEnd) and Switch + // (Default/0/1/...) uniformly. + return (kind, arm.PinName) switch + { + (CFGStatementKind.Loop, "LoopBody") => CFGEdgeType.LoopBody, + (CFGStatementKind.Loop, "LoopEnd") => CFGEdgeType.LoopExit, + (CFGStatementKind.Switch, _) => CFGEdgeType.Switch, + (_, "False") => CFGEdgeType.BranchFalse, + (_, "LoopEnd") => CFGEdgeType.LoopExit, + _ => CFGEdgeType.BranchTrue + }; } // ─── Node Type Helpers ──────────────────────────────────────────── diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BS2CFGConverter.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BS2CFGConverter.cs index eaf3d61d..58c57949 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BS2CFGConverter.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BS2CFGConverter.cs @@ -141,9 +141,13 @@ private List FormatFlowControl(FlowControlStatement flowCtrl, stri FunctionName = functionName, ConditionPubVar = condPubVar, ConditionExpression = flowCtrl.ConditionExpression, - TrueBlockName = flowCtrl.TrueBlockName, - FalseBlockName = flowCtrl.FalseBlockName, - ToLoopCondReturnTo = flowCtrl.ToLoopCondReturnTo, + // Copy the full arm list so N-way Switch and any variadic shape survive. + Arms = flowCtrl.Arms.Select(a => new BranchArm + { + PinName = a.PinName, + TargetBlockName = a.TargetBlockName, + IsLoopback = a.IsLoopback + }).ToList(), OriginalExpression = flowCtrl.SourceCode, SourceLine = flowCtrl.LineNumber }; @@ -180,6 +184,7 @@ private static string GetFunctionNameFromFlowControl(FlowControlStatement flowCt { FlowControlType.Branch => Branch, FlowControlType.Loop => Loop, + FlowControlType.Switch => Switch, FlowControlType.ToLoopCond => ToLoopCond, FlowControlType.Break => Break, _ => string.Empty @@ -189,6 +194,7 @@ private static string GetFunctionNameFromFlowControl(FlowControlStatement flowCt { FlowControlType.Branch => CFGStatementKind.Branch, FlowControlType.Loop => CFGStatementKind.Loop, + FlowControlType.Switch => CFGStatementKind.Switch, FlowControlType.ToLoopCond => CFGStatementKind.ToLoopCond, FlowControlType.Break => CFGStatementKind.Break, _ => CFGStatementKind.Unknown diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFG2BSConverter.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFG2BSConverter.cs index 25457039..c2f030c0 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFG2BSConverter.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFG2BSConverter.cs @@ -109,6 +109,7 @@ public BlockScript Generate(ControlFlowGraph cfg) { [CFGStatementKind.Branch] = FlowControlType.Branch, [CFGStatementKind.Loop] = FlowControlType.Loop, + [CFGStatementKind.Switch] = FlowControlType.Switch, [CFGStatementKind.ToLoopCond] = FlowControlType.ToLoopCond, [CFGStatementKind.Break] = FlowControlType.Break, }; @@ -123,9 +124,13 @@ public BlockScript Generate(ControlFlowGraph cfg) StatementId = cfgStmt.StatementId, ControlType = controlType.Value, ConditionExpression = cfgStmt.ConditionExpression ?? string.Empty, - TrueBlockName = cfgStmt.TrueBlockName ?? string.Empty, - FalseBlockName = cfgStmt.FalseBlockName ?? string.Empty, - ToLoopCondReturnTo = cfgStmt.ToLoopCondReturnTo, + // Copy the full arm list so N-way Switch and any variadic shape survive. + Arms = cfgStmt.Arms.Select(a => new BranchArm + { + PinName = a.PinName, + TargetBlockName = a.TargetBlockName, + IsLoopback = a.IsLoopback + }).ToList(), SourceCode = cfgStmt.OriginalExpression, LineNumber = cfgStmt.SourceLine }; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFGConditionDuplicator.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFGConditionDuplicator.cs index 9bc545e7..a943077e 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFGConditionDuplicator.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFGConditionDuplicator.cs @@ -1,3 +1,5 @@ +using KitX.Core.Contract.Workflow; + namespace KitX.Workflow.CFG; /// @@ -80,9 +82,13 @@ private static void InsertConditionDuplicates(CFGBlock bodyBlock, List new BranchArm + { + PinName = a.PinName, + TargetBlockName = a.TargetBlockName, + IsLoopback = a.IsLoopback + }).ToList(), Fingerprint = source.Fingerprint, IsLoopConditionDuplication = true }; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/DataEdgeBuilder.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/DataEdgeBuilder.cs index 7c0c729c..6faa47a0 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/DataEdgeBuilder.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/DataEdgeBuilder.cs @@ -67,10 +67,12 @@ private void ProcessStatement(CFGStatement stmt, PipelineContext context) } } - // Connect ConditionPubVar to any Boolean-type input pin (Branch/Loop condition) + // Connect ConditionPubVar to the flow-control node's condition/selector input pin. + // This is the first non-Exec data input pin (Branch/Loop "Condition" is Boolean, + // Switch "Selector" is Integer), so match by position rather than hard-coding Boolean. if (!string.IsNullOrEmpty(stmt.ConditionPubVar)) { - var condPin = funcDef.InputPins.FirstOrDefault(p => p.Type == PinType.Boolean); + var condPin = funcDef.InputPins.FirstOrDefault(p => p.Type != PinType.Execution); if (condPin != null) ConnectPubVarSource(stmt.ConditionPubVar, targetNode, condPin.Name, context); } diff --git a/KitX Standard b/KitX Standard index 7d256fca..d9996ce8 160000 --- a/KitX Standard +++ b/KitX Standard @@ -1 +1 @@ -Subproject commit 7d256fca55ecde81fa115dd16c2fce030c01a803 +Subproject commit d9996ce8cae8e58bff9c37d8b5d61ea4a9ed2672 From 76dd1cd7d0d5dc72bc5d5b3ed2caaea884080236 Mon Sep 17 00:00:00 2001 From: StarInk Date: Sat, 20 Jun 2026 02:46:09 +0200 Subject: [PATCH 093/110] =?UTF-8?q?=F0=9F=94=A7=20Fix(Workflow):=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20ToLoopCondReturnTo=20=E4=B8=8E=20TrueBlock?= =?UTF-8?q?Name=20=E7=9A=84=20Arms[0]=20=E5=86=B2=E7=AA=81=E5=9B=9E?= =?UTF-8?q?=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 根因:BranchArm 重构时,CFGStatement/FlowControlStatement 的 TrueBlockName 与 ToLoopCondReturnTo 便利访问器都写 Arms[0],而 BlockStatementExtractor. CreateLoopBlocksForBlock 在 Loop 语句上设 ToLoopCondReturnTo 覆盖了 True arm, 导致 LoopBody 不可达、循环体块为空(Test D/H/I DIFF) - 同时:BS2CFG/CFG2BS/BP2CFG/CFGConditionDuplicator 只复制 Arms 未显式复制 ToLoopCondReturnTo 独立字段,ToLoopCond 丢失 loopback target,循环不回边(K/L 超时/输出不全) - 修复:ToLoopCondReturnTo 改为独立字段(不路由 Arms[0]),并在 4 处转换器显式复制 Verified: Test A-V 全 PASS,Switch 往返 PASS --- .../KitX.Workflow/CFG/CFGStatement.cs | 15 +++++++++------ .../Models/Statements/FlowControlStatement.cs | 13 +++++++------ .../KitX.Workflow/Conversion/BP2CFGConverter.cs | 4 ++++ .../KitX.Workflow/Conversion/BS2CFGConverter.cs | 3 +++ .../KitX.Workflow/Conversion/CFG2BSConverter.cs | 3 +++ .../Conversion/CFGConditionDuplicator.cs | 2 ++ 6 files changed, 28 insertions(+), 12 deletions(-) diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/CFG/CFGStatement.cs b/KitX Clients/KitX Workflow/KitX.Workflow/CFG/CFGStatement.cs index 200eefa6..defd2ab9 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/CFG/CFGStatement.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/CFG/CFGStatement.cs @@ -147,12 +147,15 @@ public string? FalseBlockName set => SetArm(1, "False", value); } - /// Convenience accessor: the ToLoopCond loopback target (Arms[0], IsLoopback=true). - public string? ToLoopCondReturnTo - { - get => Arms.Count > 0 ? Arms[0].TargetBlockName : null; - set => SetArm(0, "Exec", value ?? string.Empty, isLoopback: true); - } + /// + /// The ToLoopCond loopback target block name. Kept as a SEPARATE field (not routed through + /// Arms[0]) so that setting it on a Loop/Branch statement — which BlockStatementExtractor + /// .CreateLoopBlocksForBlock does to record the loop's own block as the loopback target — + /// does NOT clobber (Arms[0]). On a ToLoopCond statement this is + /// the sole target; on a Loop/Branch statement it carries the owning loop's condition block + /// for back-edge resolution and is independent of the Branch/Loop arms. + /// + public string? ToLoopCondReturnTo { get; set; } private void SetArm(int index, string pinName, string? value, bool isLoopback = false) { diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/Statements/FlowControlStatement.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/Statements/FlowControlStatement.cs index a959b854..a8cb66d3 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/Statements/FlowControlStatement.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/Statements/FlowControlStatement.cs @@ -49,13 +49,14 @@ public string FalseBlockName } /// - /// Convenience: the loopback target for ToLoopCond (Arms[0].TargetBlockName). + /// The ToLoopCond loopback target block name. Kept as a SEPARATE field (not routed through + /// Arms[0]) so that setting it on a Loop/Branch statement — which BlockStatementExtractor + /// .CreateLoopBlocksForBlock does to record the loop's own block as the loopback target — + /// does NOT clobber (Arms[0]). On a ToLoopCond statement this is + /// the sole target; on a Loop/Branch statement it carries the owning loop's condition block + /// for back-edge resolution and is independent of the Branch/Loop arms. /// - public string? ToLoopCondReturnTo - { - get => Arms.Count > 0 ? Arms[0].TargetBlockName : null; - set => SetArm(0, "Exec", value ?? string.Empty, isLoopback: true); - } + public string? ToLoopCondReturnTo { get; set; } private void SetArm(int index, string pinName, string value, bool isLoopback = false) { diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BP2CFGConverter.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BP2CFGConverter.cs index 22698a7a..67d15ebf 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BP2CFGConverter.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BP2CFGConverter.cs @@ -938,6 +938,10 @@ private CFGStatement ConvertBlockStatementToCfgStatement(BlockStatement blockStm TargetBlockName = a.TargetBlockName, IsLoopback = a.IsLoopback }).ToList(); + // ToLoopCondReturnTo is a separate field (not in Arms) — copy it explicitly so + // ToLoopCond statements (and Loop statements carrying a loopback target set by + // BlockStatementExtractor.CreateLoopBlocksForBlock) retain it across BP→CFG. + cfgStmt.ToLoopCondReturnTo = flow.ToLoopCondReturnTo; cfgStmt.ConditionPubVar = flow.ConditionExpression?.Trim(); break; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BS2CFGConverter.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BS2CFGConverter.cs index 58c57949..2b598a98 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BS2CFGConverter.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BS2CFGConverter.cs @@ -148,6 +148,9 @@ private List FormatFlowControl(FlowControlStatement flowCtrl, stri TargetBlockName = a.TargetBlockName, IsLoopback = a.IsLoopback }).ToList(), + // ToLoopCondReturnTo is a separate field (not in Arms) — copy it explicitly so + // ToLoopCond statements retain their loopback target across BS→CFG. + ToLoopCondReturnTo = flowCtrl.ToLoopCondReturnTo, OriginalExpression = flowCtrl.SourceCode, SourceLine = flowCtrl.LineNumber }; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFG2BSConverter.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFG2BSConverter.cs index c2f030c0..88f63dcf 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFG2BSConverter.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFG2BSConverter.cs @@ -131,6 +131,9 @@ public BlockScript Generate(ControlFlowGraph cfg) TargetBlockName = a.TargetBlockName, IsLoopback = a.IsLoopback }).ToList(), + // ToLoopCondReturnTo is a separate field (not in Arms) — copy it explicitly so + // ToLoopCond statements retain their loopback target across CFG→BS. + ToLoopCondReturnTo = cfgStmt.ToLoopCondReturnTo, SourceCode = cfgStmt.OriginalExpression, LineNumber = cfgStmt.SourceLine }; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFGConditionDuplicator.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFGConditionDuplicator.cs index a943077e..99ab6c3e 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFGConditionDuplicator.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFGConditionDuplicator.cs @@ -89,6 +89,8 @@ private static void InsertConditionDuplicates(CFGBlock bodyBlock, List Date: Sat, 20 Jun 2026 03:25:51 +0200 Subject: [PATCH 094/110] =?UTF-8?q?=F0=9F=A7=A9=20Refactor(Workflow):=20?= =?UTF-8?q?=E5=88=A0=E9=99=A4=E7=BA=AF=E6=AD=BB=E4=BB=A3=E7=A0=81-KISS?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=98=B6=E6=AE=B5L0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 删除 3 处零消费的死代码/死字段: 1. BP2CFGConverter.cs: 删除 BuildEdgesFromTopology 中与上一段逐字重复的 ToLoopCond/Break 分支(首段已 continue,次段不可达)。 2. NodeDescriptor: 删除 Width/Height 两个零消费 record 参数。LayoutService 读的是节点实例 BlueprintNode.Width/Height(默认 200/100),从不读 descriptor 级尺寸。同步移除 NodeRegistry 构造与 7 个节点子类 GetDescriptor() 的 Width:/Height: 参数(子模块 03f800f)。 3. CFGBlockType.LoopExit: 删除零消费枚举成员(注意与 CFGEdgeType.LoopExit 为不同枚举,后者仍活)。 核实保留:FlowControlType.Return 经 grep 确认仍被 BlockScriptParser:251 与 BlockStatementExtractor:306 使用,非死字段,本轮不动。 验证:dotnet build 0 错误;22 测试 A-V 全 PASS(32 PASS / 0 FAIL)。 # KitX Workflow KISS 深度重构 L0 阶段:删除纯死代码 # 后续 L1-L9 见会话 todo 列表,分阶段推进。 # Date: 2026-06-19 # Author: StarInk --- .../KitX.Workflow/Blueprint/NodeRegistry.cs | 1 - .../KitX.Workflow/CFG/CFGBlock.cs | 3 --- .../Conversion/BP2CFGConverter.cs | 23 ------------------- KitX Standard | 2 +- 4 files changed, 1 insertion(+), 28 deletions(-) diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Blueprint/NodeRegistry.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Blueprint/NodeRegistry.cs index 0f46d8b8..d87cf4d1 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Blueprint/NodeRegistry.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Blueprint/NodeRegistry.cs @@ -87,7 +87,6 @@ public BlueprintNode CreateBuiltinFunctionNode(string functionName) }; var descriptor = new NodeDescriptor( - def.NodeWidth, def.NodeHeight, def.InputPins, def.OutputPins, def.DisplayName, def.InputVariadic, diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/CFG/CFGBlock.cs b/KitX Clients/KitX Workflow/KitX.Workflow/CFG/CFGBlock.cs index 72263e1a..0bb086a3 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/CFG/CFGBlock.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/CFG/CFGBlock.cs @@ -19,9 +19,6 @@ public enum CFGBlockType /// The body of a loop — may reach back to the LoopHeader via ToLoopCond. LoopBody, - - /// The exit block of a loop (what runs after the loop condition is false). - LoopExit, } /// diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BP2CFGConverter.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BP2CFGConverter.cs index 67d15ebf..86bea355 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BP2CFGConverter.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BP2CFGConverter.cs @@ -594,29 +594,6 @@ private void BuildEdgesFromTopology( continue; } - if (lastStmt.Kind == CFGStatementKind.ToLoopCond) - { - if (!string.IsNullOrEmpty(lastStmt.ToLoopCondReturnTo)) - block.Successors.Add(new CFGEdge - { - FromBlockName = block.Name, - ToBlockName = lastStmt.ToLoopCondReturnTo, - Type = CFGEdgeType.LoopbackToCondition - }); - continue; - } - - if (lastStmt.Kind == CFGStatementKind.Break) - { - block.Successors.Add(new CFGEdge - { - FromBlockName = block.Name, - ToBlockName = "__break__", - Type = CFGEdgeType.Break - }); - continue; - } - // Sequential fall-through: derive NextBlockName from the block or from exec connections if (!string.IsNullOrEmpty(block.NextBlockName)) { diff --git a/KitX Standard b/KitX Standard index d9996ce8..03f800fe 160000 --- a/KitX Standard +++ b/KitX Standard @@ -1 +1 @@ -Subproject commit d9996ce8cae8e58bff9c37d8b5d61ea4a9ed2672 +Subproject commit 03f800feb2f0a3d1c3335534bb91c90fc59f7ed6 From ff9ee615406a6885084af541b80cdcd3c3865c96 Mon Sep 17 00:00:00 2001 From: StarInk Date: Sat, 20 Jun 2026 03:29:41 +0200 Subject: [PATCH 095/110] =?UTF-8?q?=F0=9F=A7=A9=20Refactor(Workflow):=20?= =?UTF-8?q?=E6=9C=BA=E6=A2=B0=E5=8E=BB=E9=87=8D-KISS=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E9=98=B6=E6=AE=B5L1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 消除 6 处字符级重复代码块,不改变行为: 1. BranchArm.Clone(): 四处 Select(a => new BranchArm{...}).ToList() 收敛为 Select(a => a.Clone()).ToList()(子模块 829afd3)。 2. ControlFlowMapping 共享表: 新建 Conversion/ControlFlowMapping.cs, 统一 FlowControlType↔CFGStatementKind 与 →FunctionName 三张映射。 替换 BS2CFG(MapControlTypeToKind/MapControlTypeToFunctionName)、 BP2CFG(ConvertBlockStatementToCfgStatement 内联 switch)、 CFG2BS(KindToControlType 字典) 三处重复映射。 3. ExtractStatements 扁平化: 提取 AddLocalVarDeclarations / AddExpressionStatement / AddInvocationStatement / TryHandleNextBlockStringAssign / BuildExpressionStatement 五个方法, 6 个 new ExpressionStatement 初始化器收敛为单一工厂,嵌套从 5 层降到 2 层。 NextBlock 字符串特判保留(区别于 FlowControl 的 NextBlock=Branch(...))。 4. CSCompiler.LoadOrCompile: 抽私有方法统一内存/磁盘缓存、Roslyn 编译、 ALC 加载、实例化、持久化、异常处理 5 段公共逻辑;CompileScript 与 CompileFromCFG 仅负责派生 hash + 构造 buildUnit 闭包。抽 PrepPubVarNames 消除 196-204 与 FormatAndInferTypes 268-277 两处 PubVarNames 复制。 5. BlockScriptExecutor.ExecuteCoreAsync: 抽私有方法统一 stopwatch/try-catch/ globals/parameters/RunAsync/三态返回;ExecuteAsync(public) 与 ExecuteFromCFGAsync(internal) 注入编译闭包与日志标签,可见性维持原状。 验证:dotnet build 0 错误;22 测试 A-V 全 PASS(32 PASS / 0 FAIL), 含 D/H/I/O4/V 往返测试。 # KitX Workflow KISS 深度重构 L1 阶段:机械去重 # 后续 L2-L9 见会话 todo 列表,分阶段推进。 # Date: 2026-06-19 # Author: StarInk --- .../BlockScripting/BlockScriptExecutor.cs | 100 ++---- .../BlockScripting/BlockStatementExtractor.cs | 295 +++++++++--------- .../BlockScripting/CSCompiler.cs | 149 ++++----- .../Conversion/BP2CFGConverter.cs | 29 +- .../Conversion/BS2CFGConverter.cs | 31 +- .../Conversion/CFG2BSConverter.cs | 19 +- .../Conversion/CFGConditionDuplicator.cs | 7 +- .../Conversion/ControlFlowMapping.cs | 54 ++++ KitX Standard | 2 +- 9 files changed, 306 insertions(+), 380 deletions(-) create mode 100644 KitX Clients/KitX Workflow/KitX.Workflow/Conversion/ControlFlowMapping.cs diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptExecutor.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptExecutor.cs index df5f94cd..6362e5a9 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptExecutor.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptExecutor.cs @@ -102,70 +102,14 @@ public async Task ExecuteAsync( BlockScript script, Dictionary? parameters = null, CancellationToken cancellationToken = default) - { - _stopwatch.Restart(); - _output = new List(); - - try - { - CFG2CSGenerator.IsDebugMode = _debugger != null; - - // Full-script assembly compilation - var compiled = _assemblyCompiler.CompileScript(script, _workflowId, out var compileErrors); - if (compiled == null) + => await ExecuteCoreAsync(script, parameters, cancellationToken, + () => { - _stopwatch.Stop(); - return new BlockScriptExecutionResult - { - IsSuccess = false, - ErrorMessage = FormatCompileErrors(compileErrors), - ExecutionTimeMs = _stopwatch.ElapsedMilliseconds, - Output = _output - }; - } - - Log.Debug("[BlockScriptExecutor] Using assembly-compiled execution path"); - _globals = new BlockScriptExecutionGlobals(_scopeManager, _output, _pluginManager); - _globals.Debugger = _debugger; - _globals.ResetRunState(); - _scopeManager.InitializeGlobalScope(script); - - // Import parameters - if (parameters != null) - { - foreach (var p in parameters) - _globals.Set(p.Key, p.Value); - } - - await compiled.RunAsync(_globals, cancellationToken); - - _stopwatch.Stop(); - return new BlockScriptExecutionResult - { - IsSuccess = true, - ExecutedBlockCount = _globals.ExecutedBlockCount, - ExecutionTimeMs = _stopwatch.ElapsedMilliseconds, - Output = _output - }; - } - catch (OperationCanceledException) - { - _stopwatch.Stop(); - throw; - } - catch (Exception ex) - { - _stopwatch.Stop(); - Log.Error(ex, "[BlockScriptExecutor] Error executing block script"); - return new BlockScriptExecutionResult - { - IsSuccess = false, - ErrorMessage = $"Execution error: {ex.Message}", - ExecutionTimeMs = _stopwatch.ElapsedMilliseconds, - Output = _output - }; - } - } + var compiled = _assemblyCompiler.CompileScript(script, _workflowId, out var compileErrors); + return (compiled, compileErrors); + }, + "assembly-compiled execution path", + "Error executing block script"); /// /// Executes a BlockScript using a pre-built CFG (BP→CFG→CS direct path). @@ -176,6 +120,29 @@ internal async Task ExecuteFromCFGAsync( ControlFlowGraph cfg, Dictionary? parameters = null, CancellationToken cancellationToken = default) + => await ExecuteCoreAsync(script, parameters, cancellationToken, + () => + { + var compiled = _assemblyCompiler.CompileFromCFG(cfg, script, _workflowId, out var compileErrors); + return (compiled, compileErrors); + }, + "BP→CFG→CS direct execution path", + "Error executing from CFG"); + + /// + /// Shared execute pipeline for and + /// . The two public methods differ only in the + /// compile call and two log strings; everything else (stopwatch, debug-mode flag, + /// null-compiled failure result, globals setup, parameter import, RunAsync, and the + /// success/cancel/exception result handling) is identical and lives here once. + /// + private async Task ExecuteCoreAsync( + BlockScript script, + Dictionary? parameters, + CancellationToken cancellationToken, + Func<(ICompiledBlockScript? compiled, IReadOnlyList compileErrors)> compileFn, + string pathLabel, + string errorLabel) { _stopwatch.Restart(); _output = new List(); @@ -184,7 +151,7 @@ internal async Task ExecuteFromCFGAsync( { CFG2CSGenerator.IsDebugMode = _debugger != null; - var compiled = _assemblyCompiler.CompileFromCFG(cfg, script, _workflowId, out var compileErrors); + var (compiled, compileErrors) = compileFn(); if (compiled == null) { _stopwatch.Stop(); @@ -197,12 +164,13 @@ internal async Task ExecuteFromCFGAsync( }; } - Log.Debug("[BlockScriptExecutor] Using BP→CFG→CS direct execution path"); + Log.Debug("[BlockScriptExecutor] Using {PathLabel}", pathLabel); _globals = new BlockScriptExecutionGlobals(_scopeManager, _output, _pluginManager); _globals.Debugger = _debugger; _globals.ResetRunState(); _scopeManager.InitializeGlobalScope(script); + // Import parameters if (parameters != null) { foreach (var p in parameters) @@ -228,7 +196,7 @@ internal async Task ExecuteFromCFGAsync( catch (Exception ex) { _stopwatch.Stop(); - Log.Error(ex, "[BlockScriptExecutor] Error executing from CFG"); + Log.Error(ex, "[BlockScriptExecutor] {ErrorLabel}", errorLabel); return new BlockScriptExecutionResult { IsSuccess = false, diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockStatementExtractor.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockStatementExtractor.cs index bd399a62..100ed4ee 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockStatementExtractor.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockStatementExtractor.cs @@ -128,7 +128,11 @@ public void CreateLoopBlocksForBlock(BlockDefinition block, BlockScript script) } /// - /// Extracts statements from syntax root into block definition + /// Extracts statements from syntax root into block definition. + /// Dispatches each descendant node to a focused helper, keeping the top-level + /// foreach flat (2 levels max). The 6 former copy-pasted + /// new ExpressionStatement { ... } initializers collapse into the single + /// factory. /// private void ExtractStatements( SyntaxNode root, BlockDefinition block, BlockType blockType, @@ -151,164 +155,161 @@ private void ExtractStatements( foreach (var node in root.DescendantNodes()) { - if (node is LocalDeclarationStatementSyntax varDecl) + switch (node) { - foreach (var variable in varDecl.Declaration.Variables) - { - var varDefinition = new VariableDeclaration + case LocalDeclarationStatementSyntax varDecl: + AddLocalVarDeclarations(varDecl, block); + break; + case ExpressionStatementSyntax exprStmt: + AddExpressionStatement(exprStmt, block, blockType, diagnostics); + break; + case ReturnStatementSyntax returnStmt: + block.Statements.Add(new FlowControlStatement { - Name = variable.Identifier.Text, - Type = varDecl.Declaration.Type.ToString(), - InitialValueExpression = variable.Initializer?.Value?.ToString() - }; + LineNumber = returnStmt.GetLineNumber(), + SourceCode = returnStmt.ToFullString(), + ControlType = FlowControlType.Return, + ConditionExpression = returnStmt.Expression?.ToString() ?? string.Empty + }); + break; + } + } + } - if (variable.Initializer?.Value is LiteralExpressionSyntax literal) - { - varDefinition.DefaultValue = ExprUtils.GetLiteralValue(literal); - } + /// + /// Lifts LocalDeclarationStatementSyntax variables into , + /// pre-evaluating literal initialisers. + /// + private static void AddLocalVarDeclarations(LocalDeclarationStatementSyntax varDecl, BlockDefinition block) + { + foreach (var variable in varDecl.Declaration.Variables) + { + var varDefinition = new VariableDeclaration + { + Name = variable.Identifier.Text, + Type = varDecl.Declaration.Type.ToString(), + InitialValueExpression = variable.Initializer?.Value?.ToString() + }; - block.Variables.Add(varDefinition); - } - } - else if (node is ExpressionStatementSyntax exprStmt) + if (variable.Initializer?.Value is LiteralExpressionSyntax literal) { - var exprText = exprStmt.Expression.ToString(); + varDefinition.DefaultValue = ExprUtils.GetLiteralValue(literal); + } - Log.Debug("[BlockStatementExtractor] Processing ExpressionStatementSyntax: Type={ExprType}, Text={ExprText}", - exprStmt.Expression.GetType().Name, exprText); + block.Variables.Add(varDefinition); + } + } - if (exprStmt.Expression is InvocationExpressionSyntax invoke) - { - var methodName = ExprUtils.GetMethodName(invoke); + /// + /// Handles an ExpressionStatementSyntax: invocation, assignment (with or + /// without invocation RHS), NextBlock string directive, or unsupported form. + /// Uses early returns to keep nesting shallow. The registry-hit-returns-null and + /// registry-miss cases share one construction path via + /// (they previously held two + /// character-identical new ExpressionStatement blocks). + /// + private void AddExpressionStatement( + ExpressionStatementSyntax exprStmt, BlockDefinition block, + BlockType blockType, ConversionDiagnostics? diagnostics) + { + var exprText = exprStmt.Expression.ToString(); - // Try BuiltinFunctionRegistry for all registered functions - if (_functionRegistry != null && _functionRegistry.Get(methodName) is { } funcDef) - { - var stmt = funcDef.ExtractStatement(invoke, exprStmt.GetLineNumber(), exprText); - if (stmt != null) - block.Statements.Add(stmt); - else - // Carry the already-parsed invocation so BS2CFGConverter does not - // re-parse the same expression text (eliminates the double parse). - block.Statements.Add(new ExpressionStatement - { - LineNumber = exprStmt.GetLineNumber(), - SourceCode = exprText, - Expression = exprText, - ParsedInvocation = invoke, - AssignedVariable = null - }); - } - else - { - block.Statements.Add(new ExpressionStatement - { - LineNumber = exprStmt.GetLineNumber(), - SourceCode = exprText, - Expression = exprText, - ParsedInvocation = invoke, - AssignedVariable = null - }); - } - } - else if (exprStmt.Expression is AssignmentExpressionSyntax assignment) - { - Log.Debug("[BlockStatementExtractor] Processing AssignmentExpressionSyntax: {ExprText}", exprText); - if (assignment.Right is InvocationExpressionSyntax assignInvoke) - { - var methodName = ExprUtils.GetMethodName(assignInvoke); - Log.Debug("[BlockStatementExtractor] assignment.Right is InvocationExpressionSyntax, methodName = {MethodName}", methodName); - // The LHS is the assignment target (e.g. "x" in "x = Func(...)"). Captured once - // here so BS2CFGConverter does not re-derive it by re-parsing. - var assignedVar = assignment.Left.ToString(); - - // Try BuiltinFunctionRegistry for all registered functions - if (_functionRegistry != null && _functionRegistry.Get(methodName) is { } funcDef) - { - var stmt = funcDef.ExtractStatement(assignInvoke, exprStmt.GetLineNumber(), exprText); - if (stmt != null) - block.Statements.Add(stmt); - else - block.Statements.Add(new ExpressionStatement - { - LineNumber = exprStmt.GetLineNumber(), - SourceCode = exprText, - Expression = exprText, - ParsedInvocation = assignInvoke, - AssignedVariable = assignedVar - }); - } - else - { - Log.Debug("[BlockStatementExtractor] Unknown methodName '{MethodName}', treating as ExpressionStatement", methodName); - block.Statements.Add(new ExpressionStatement - { - LineNumber = exprStmt.GetLineNumber(), - SourceCode = exprText, - Expression = exprText, - ParsedInvocation = assignInvoke, - AssignedVariable = assignedVar - }); - } - } - else - { - // Check for NextBlock = "BlockName" (plain string assignment to NextBlock) - if (assignment.Left is IdentifierNameSyntax { Identifier.Text: "NextBlock" } - && assignment.Right is LiteralExpressionSyntax nextBlockLiteral - && nextBlockLiteral.Token.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.StringLiteralToken)) - { - block.NextBlockName = nextBlockLiteral.Token.ValueText; - Log.Debug("[BlockStatementExtractor] Set NextBlockName = {NextBlockName}", block.NextBlockName); - } - else - { - Log.Debug("[BlockStatementExtractor] assignment.Right is NOT InvocationExpressionSyntax, type = {Type}", assignment.Right.GetType().Name); - // Preserve AssignedVariable so BS2CFGConverter can handle - // non-invocation RHS (e.g. v = a + b + c where RHS is BinaryExpression). - block.Statements.Add(new ExpressionStatement - { - LineNumber = exprStmt.GetLineNumber(), - SourceCode = exprText, - Expression = exprText, - AssignedVariable = assignment.Left.ToString(), - ParsedInvocation = assignment.Right as InvocationExpressionSyntax - }); - } - } - } - else - { - // Not an invocation and not an assignment — per BlockScript grammar a statement - // must be a function call, an assignment, or a NextBlock/flow-control directive. - // This is likely a user error; record a warning (non-fatal) so the editor can - // surface it instead of silently dropping the statement downstream. - var lineNum = exprStmt.GetLineNumber(); - diagnostics?.AddWarning("BS_UNSUPPORTED_EXPR", - $"Unsupported expression form '{exprStmt.Expression.GetType().Name}' in {blockType}: {exprText}", - lineNum); - Log.Debug("[BlockStatementExtractor] Unhandled expression type in {BlockType}: {Type} = {Expr}", - blockType, exprStmt.Expression.GetType().Name, exprText); - block.Statements.Add(new ExpressionStatement - { - LineNumber = lineNum, - SourceCode = exprText, - Expression = exprText - }); - } - } - else if (node is ReturnStatementSyntax returnStmt) + Log.Debug("[BlockStatementExtractor] Processing ExpressionStatementSyntax: Type={ExprType}, Text={ExprText}", + exprStmt.Expression.GetType().Name, exprText); + + if (exprStmt.Expression is InvocationExpressionSyntax invoke) + { + AddInvocationStatement(exprStmt, invoke, exprText, assignedVar: null, block); + return; + } + + if (exprStmt.Expression is AssignmentExpressionSyntax assignment) + { + if (TryHandleNextBlockStringAssign(assignment, block)) + return; + + if (assignment.Right is InvocationExpressionSyntax assignInvoke) { - block.Statements.Add(new FlowControlStatement - { - LineNumber = returnStmt.GetLineNumber(), - SourceCode = returnStmt.ToFullString(), - ControlType = FlowControlType.Return, - ConditionExpression = returnStmt.Expression?.ToString() ?? string.Empty - }); + AddInvocationStatement(exprStmt, assignInvoke, exprText, + assignedVar: assignment.Left.ToString(), block); + return; } + + Log.Debug("[BlockStatementExtractor] assignment.Right is NOT InvocationExpressionSyntax, type = {Type}", + assignment.Right.GetType().Name); + // Preserve AssignedVariable so BS2CFGConverter can handle non-invocation RHS + // (e.g. v = a + b + c where RHS is BinaryExpression). + block.Statements.Add(BuildExpressionStatement(exprStmt, exprText, + parsedInvocation: assignment.Right as InvocationExpressionSyntax, + assignedVar: assignment.Left.ToString())); + return; } + + // Not an invocation and not an assignment — per BlockScript grammar a statement + // must be a function call, an assignment, or a NextBlock/flow-control directive. + // This is likely a user error; record a warning (non-fatal) so the editor can + // surface it instead of silently dropping the statement downstream. + var lineNum = exprStmt.GetLineNumber(); + diagnostics?.AddWarning("BS_UNSUPPORTED_EXPR", + $"Unsupported expression form '{exprStmt.Expression.GetType().Name}' in {blockType}: {exprText}", + lineNum); + Log.Debug("[BlockStatementExtractor] Unhandled expression type in {BlockType}: {Type} = {Expr}", + blockType, exprStmt.Expression.GetType().Name, exprText); + block.Statements.Add(BuildExpressionStatement(exprStmt, exprText, + parsedInvocation: null, assignedVar: null)); } + + /// + /// Handles an invocation expression statement (bare call or call assigned to a + /// variable). Consults the builtin registry first; if it produces a statement it + /// is used directly, otherwise a generic carries + /// the already-parsed invocation so BS2CFGConverter does not re-parse the same + /// expression text (eliminates the double parse). The registry-hit-returns-null + /// and registry-miss cases converge on . + /// + private void AddInvocationStatement( + ExpressionStatementSyntax exprStmt, InvocationExpressionSyntax invoke, + string exprText, string? assignedVar, BlockDefinition block) + { + var methodName = ExprUtils.GetMethodName(invoke); + var stmt = _functionRegistry?.Get(methodName)?.ExtractStatement(invoke, exprStmt.GetLineNumber(), exprText); + block.Statements.Add(stmt ?? BuildExpressionStatement(exprStmt, exprText, invoke, assignedVar)); + } + + /// + /// Recognises the plain NextBlock = "BlockName" string-literal directive + /// (distinct from the FlowControl form NextBlock = Branch(...), which is + /// dropped upstream in BS2CFGConverter). Sets + /// and returns true when handled, false otherwise. + /// + private static bool TryHandleNextBlockStringAssign(AssignmentExpressionSyntax assignment, BlockDefinition block) + { + if (assignment.Left is IdentifierNameSyntax { Identifier.Text: "NextBlock" } + && assignment.Right is LiteralExpressionSyntax nextBlockLiteral + && nextBlockLiteral.Token.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.StringLiteralToken)) + { + block.NextBlockName = nextBlockLiteral.Token.ValueText; + Log.Debug("[BlockStatementExtractor] Set NextBlockName = {NextBlockName}", block.NextBlockName); + return true; + } + return false; + } + + /// + /// Single factory for the generic fallback. + /// Replaces the six near-identical inline initializers (two pairs of which were + /// character-for-character identical: registry-hit-returns-null vs registry-miss). + /// + private static ExpressionStatement BuildExpressionStatement( + ExpressionStatementSyntax exprStmt, string exprText, + InvocationExpressionSyntax? parsedInvocation, string? assignedVar) => new() + { + LineNumber = exprStmt.GetLineNumber(), + SourceCode = exprText, + Expression = exprText, + ParsedInvocation = parsedInvocation, + AssignedVariable = assignedVar + }; } /// diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/CSCompiler.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/CSCompiler.cs index 27a685d8..1a164852 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/CSCompiler.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/CSCompiler.cs @@ -2,6 +2,7 @@ using Serilog; using KitX.Workflow.Conversion; using KitX.Workflow.CFG; +using Microsoft.CodeAnalysis.CSharp.Syntax; namespace KitX.Workflow.BlockScripting; @@ -78,77 +79,14 @@ public CSCompiler() string? workflowId, out IReadOnlyList compileErrors) { - compileErrors = Array.Empty(); var hash = ScriptCompilationBackend.ComputeScriptHash(script); - - // Step 1: Check in-memory cache - if (_cache.TryGetValue(hash, out var entry) && entry.IsAlive) - { - Log.Debug("[CSCompiler] Memory cache hit for hash '{Hash}'", hash); - return entry.Instance; - } - - // Step 2: Try loading from disk (if workflowId provided) - if (workflowId != null) - { - var diskInstance = _persistence.TryLoadFromDisk(workflowId, hash); - if (diskInstance != null) - { - Log.Debug("[CSCompiler] Disk cache hit for hash '{Hash}' (workflow: {WfId})", - hash, workflowId); - return diskInstance; - } - } - - // Step 3: Roslyn compilation - try + return LoadOrCompile(hash, workflowId, () => { // Phase 1: Format script + infer PubVar types var (formattedScript, pubVarTypes) = FormatAndInferTypes(script); - // Phase 2: Generate CompilationUnitSyntax - var compilationUnit = CFG2CSGenerator.GenerateCompilationUnit( - script, formattedScript, pubVarTypes, hash); - - // Phase 3: Compile via CSharpCompilation - var assembly = ScriptCompilationBackend.CompileToAssembly(compilationUnit, hash, out var errors); - if (assembly == null) - { - compileErrors = errors ?? Array.Empty(); - return null; - } - - // Phase 4: Load into collectible ALContext and instantiate - var alc = new CollectibleAssemblyLoadContext(hash); - var loadedAssembly = alc.LoadFromStream(assembly); - var typeName = $"KitX.Workflow.BlockScripting.Generated.CompiledScript_{hash}"; - var scriptType = loadedAssembly.GetType(typeName); - if (scriptType == null) - { - Log.Warning("[CSCompiler] Compiled type not found in assembly"); - compileErrors = new[] { "Compiled type not found in generated assembly (workflow engine bug)." }; - alc.Unload(); - return null; - } - - var instance = (ICompiledBlockScript)Activator.CreateInstance(scriptType)!; - _cache[hash] = new CompiledScriptEntry(instance, alc); - - // Step 5: Persist to disk (if workflowId provided) - if (workflowId != null) - { - _persistence.SaveToDisk(workflowId, hash, assembly, typeName); - } - - Log.Debug("[CSCompiler] Successfully compiled and cached script hash '{Hash}'", hash); - return instance; - } - catch (Exception ex) - { - Log.Warning(ex, "[CSCompiler] Compilation threw an exception"); - compileErrors = new[] { $"Compilation threw an exception: {ex.Message}" }; - return null; - } + return CFG2CSGenerator.GenerateCompilationUnit(script, formattedScript, pubVarTypes, hash); + }, "Successfully compiled and cached script hash", out compileErrors); } /// @@ -169,42 +107,59 @@ public CSCompiler() string? workflowId, out IReadOnlyList compileErrors) { - compileErrors = Array.Empty(); var baseHash = ScriptCompilationBackend.ComputeScriptHash(script); var hash = CFG2CSGenerator.IsDebugMode ? $"debug_{baseHash}" : baseHash; + return LoadOrCompile(hash, workflowId, () => + { + Log.Debug("[CSCompiler] Compiling from pre-built CFG: {BlockCount} blocks", cfg.Blocks.Count); + var context = new PipelineContext { Script = script }; + PrepPubVarNames(context, script); + var pubVarTypes = CFG2CSGenerator.InferPubVarTypes(cfg, script.HelperFunctions, context); + return CFG2CSGenerator.GenerateCompilationUnit(script, cfg, pubVarTypes, hash); + }, "CompileFromCFG success for hash", out compileErrors); + } + /// + /// Shared compile/cache/load/persist pipeline. Both and + /// delegate here, supplying a + /// closure that performs their Phase 1 (format/infer) differences and returns the + /// CompilationUnitSyntax. The cache-lookup / Roslyn compile / ALC load / + /// instantiate / disk-persist / exception-handling tail is identical across both + /// paths and lives here once. + /// + private ICompiledBlockScript? LoadOrCompile( + string hash, string? workflowId, + Func buildUnit, + string successLogPrefix, + out IReadOnlyList compileErrors) + { + compileErrors = Array.Empty(); + + // Step 1: Check in-memory cache if (_cache.TryGetValue(hash, out var entry) && entry.IsAlive) { Log.Debug("[CSCompiler] Memory cache hit for hash '{Hash}'", hash); return entry.Instance; } + // Step 2: Try loading from disk (if workflowId provided) if (workflowId != null) { var diskInstance = _persistence.TryLoadFromDisk(workflowId, hash); if (diskInstance != null) { - Log.Debug("[CSCompiler] Disk cache hit for hash '{Hash}'", hash); + Log.Debug("[CSCompiler] Disk cache hit for hash '{Hash}' (workflow: {WfId})", + hash, workflowId); return diskInstance; } } + // Step 3: Roslyn compilation try { - Log.Debug("[CSCompiler] Compiling from pre-built CFG: {BlockCount} blocks", cfg.Blocks.Count); + var compilationUnit = buildUnit(); - var context = new PipelineContext { Script = script }; - if (script.PubVarBlock != null) - { - foreach (var variable in script.PubVarBlock.Variables) - { - if (!context.PubVarNames.Contains(variable.Name)) - context.PubVarNames.Add(variable.Name); - } - } - var pubVarTypes = CFG2CSGenerator.InferPubVarTypes(cfg, script.HelperFunctions, context); - - var compilationUnit = CFG2CSGenerator.GenerateCompilationUnit(script, cfg, pubVarTypes, hash); + // Phase 3: Compile via CSharpCompilation var assembly = ScriptCompilationBackend.CompileToAssembly(compilationUnit, hash, out var errors); if (assembly == null) { @@ -212,12 +167,14 @@ public CSCompiler() return null; } + // Phase 4: Load into collectible ALContext and instantiate var alc = new CollectibleAssemblyLoadContext(hash); var loadedAssembly = alc.LoadFromStream(assembly); var typeName = $"KitX.Workflow.BlockScripting.Generated.CompiledScript_{hash}"; var scriptType = loadedAssembly.GetType(typeName); if (scriptType == null) { + Log.Warning("[CSCompiler] Compiled type not found in assembly"); compileErrors = new[] { "Compiled type not found in generated assembly (workflow engine bug)." }; alc.Unload(); return null; @@ -226,15 +183,16 @@ public CSCompiler() var instance = (ICompiledBlockScript)Activator.CreateInstance(scriptType)!; _cache[hash] = new CompiledScriptEntry(instance, alc); + // Step 5: Persist to disk (if workflowId provided) if (workflowId != null) _persistence.SaveToDisk(workflowId, hash, assembly, typeName); - Log.Debug("[CSCompiler] CompileFromCFG success for hash '{Hash}'", hash); + Log.Debug("[CSCompiler] {Prefix} '{Hash}'", successLogPrefix, hash); return instance; } catch (Exception ex) { - Log.Warning(ex, "[CSCompiler] CompileFromCFG failed"); + Log.Warning(ex, "[CSCompiler] Compilation threw an exception"); compileErrors = new[] { $"Compilation threw an exception: {ex.Message}" }; return null; } @@ -266,15 +224,7 @@ public void ClearCache() BlockScript script) { var context = new PipelineContext { Script = script }; - - if (script.PubVarBlock != null) - { - foreach (var variable in script.PubVarBlock.Variables) - { - if (!context.PubVarNames.Contains(variable.Name)) - context.PubVarNames.Add(variable.Name); - } - } + PrepPubVarNames(context, script); var formattedScript = CFGPipeline.BS2CFG(script, script.HelperFunctions ?? [], FunctionRegistry, context); @@ -293,4 +243,19 @@ public void ClearCache() var pubVarTypes = CFG2CSGenerator.InferPubVarTypes(formattedScript, script.HelperFunctions, context); return (formattedScript, pubVarTypes); } + + /// + /// Copies variable names into the pipeline + /// context's , deduplicated. Replaces two + /// character-identical inline copies that lived in CompileFromCFG and FormatAndInferTypes. + /// + private static void PrepPubVarNames(PipelineContext context, BlockScript script) + { + if (script.PubVarBlock == null) return; + foreach (var variable in script.PubVarBlock.Variables) + { + if (!context.PubVarNames.Contains(variable.Name)) + context.PubVarNames.Add(variable.Name); + } + } } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BP2CFGConverter.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BP2CFGConverter.cs index 86bea355..6fdc1538 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BP2CFGConverter.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BP2CFGConverter.cs @@ -889,32 +889,13 @@ private CFGStatement ConvertBlockStatementToCfgStatement(BlockStatement blockStm switch (blockStmt) { case FlowControlStatement flow: - cfgStmt.Kind = flow.ControlType switch - { - FlowControlType.Branch => CFGStatementKind.Branch, - FlowControlType.Loop => CFGStatementKind.Loop, - FlowControlType.Switch => CFGStatementKind.Switch, - FlowControlType.ToLoopCond => CFGStatementKind.ToLoopCond, - FlowControlType.Break => CFGStatementKind.Break, - _ => CFGStatementKind.Unknown - }; - cfgStmt.FunctionName = flow.ControlType switch - { - FlowControlType.Branch => "Branch", - FlowControlType.Loop => "Loop", - FlowControlType.Switch => "Switch", - FlowControlType.ToLoopCond => "ToLoopCond", - FlowControlType.Break => "Break", - _ => null - }; + cfgStmt.Kind = ControlFlowMapping.ToKind(flow.ControlType); + cfgStmt.FunctionName = ControlFlowMapping.ToFunctionName(flow.ControlType); + if (string.IsNullOrEmpty(cfgStmt.FunctionName)) + cfgStmt.FunctionName = null; cfgStmt.ConditionExpression = flow.ConditionExpression; // Copy the full arm list so N-way Switch and any variadic shape survive. - cfgStmt.Arms = flow.Arms.Select(a => new BranchArm - { - PinName = a.PinName, - TargetBlockName = a.TargetBlockName, - IsLoopback = a.IsLoopback - }).ToList(); + cfgStmt.Arms = flow.Arms.Select(a => a.Clone()).ToList(); // ToLoopCondReturnTo is a separate field (not in Arms) — copy it explicitly so // ToLoopCond statements (and Loop statements carrying a loopback target set by // BlockStatementExtractor.CreateLoopBlocksForBlock) retain it across BP→CFG. diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BS2CFGConverter.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BS2CFGConverter.cs index 2b598a98..60cb7c44 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BS2CFGConverter.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BS2CFGConverter.cs @@ -120,7 +120,7 @@ private List FormatFlowControl(FlowControlStatement flowCtrl, stri // Determine the function name from the source code or control type var functionName = GetFunctionNameFromFlowControl(flowCtrl); - var kind = _functionRegistry?.Get(functionName)?.StatementKind ?? MapControlTypeToKind(flowCtrl.ControlType); + var kind = _functionRegistry?.Get(functionName)?.StatementKind ?? ControlFlowMapping.ToKind(flowCtrl.ControlType); // Expand condition for Branch/Loop var hasCondition = !string.IsNullOrEmpty(flowCtrl.ConditionExpression); @@ -142,12 +142,7 @@ private List FormatFlowControl(FlowControlStatement flowCtrl, stri ConditionPubVar = condPubVar, ConditionExpression = flowCtrl.ConditionExpression, // Copy the full arm list so N-way Switch and any variadic shape survive. - Arms = flowCtrl.Arms.Select(a => new BranchArm - { - PinName = a.PinName, - TargetBlockName = a.TargetBlockName, - IsLoopback = a.IsLoopback - }).ToList(), + Arms = flowCtrl.Arms.Select(a => a.Clone()).ToList(), // ToLoopCondReturnTo is a separate field (not in Arms) — copy it explicitly so // ToLoopCond statements retain their loopback target across BS→CFG. ToLoopCondReturnTo = flowCtrl.ToLoopCondReturnTo, @@ -180,29 +175,9 @@ private static string GetFunctionNameFromFlowControl(FlowControlStatement flowCt if (parsed?.rightExpr is IdentifierNameSyntax id) return id.Identifier.Text; // Fallback: derive from ControlType - return MapControlTypeToFunctionName(flowCtrl.ControlType); + return ControlFlowMapping.ToFunctionName(flowCtrl.ControlType); } - private static string MapControlTypeToFunctionName(FlowControlType type) => type switch - { - FlowControlType.Branch => Branch, - FlowControlType.Loop => Loop, - FlowControlType.Switch => Switch, - FlowControlType.ToLoopCond => ToLoopCond, - FlowControlType.Break => Break, - _ => string.Empty - }; - - private static CFGStatementKind MapControlTypeToKind(FlowControlType type) => type switch - { - FlowControlType.Branch => CFGStatementKind.Branch, - FlowControlType.Loop => CFGStatementKind.Loop, - FlowControlType.Switch => CFGStatementKind.Switch, - FlowControlType.ToLoopCond => CFGStatementKind.ToLoopCond, - FlowControlType.Break => CFGStatementKind.Break, - _ => CFGStatementKind.Unknown - }; - // ────────────────────────────────────────────── // Expression statement formatting // ────────────────────────────────────────────── diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFG2BSConverter.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFG2BSConverter.cs index 88f63dcf..27746fe4 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFG2BSConverter.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFG2BSConverter.cs @@ -105,19 +105,11 @@ public BlockScript Generate(ControlFlowGraph cfg) // ─── Statement Conversion ────────────────────────────────────────── - private static readonly Dictionary KindToControlType = new() - { - [CFGStatementKind.Branch] = FlowControlType.Branch, - [CFGStatementKind.Loop] = FlowControlType.Loop, - [CFGStatementKind.Switch] = FlowControlType.Switch, - [CFGStatementKind.ToLoopCond] = FlowControlType.ToLoopCond, - [CFGStatementKind.Break] = FlowControlType.Break, - }; - private static BlockStatement? ConvertStatement(CFGStatement cfgStmt) { // Control flow statements → FlowControlStatement - if (KindToControlType.TryGetValue(cfgStmt.Kind, out var controlType) && controlType != null) + var controlType = ControlFlowMapping.ToControlType(cfgStmt.Kind); + if (controlType != null) { return new FlowControlStatement { @@ -125,12 +117,7 @@ public BlockScript Generate(ControlFlowGraph cfg) ControlType = controlType.Value, ConditionExpression = cfgStmt.ConditionExpression ?? string.Empty, // Copy the full arm list so N-way Switch and any variadic shape survive. - Arms = cfgStmt.Arms.Select(a => new BranchArm - { - PinName = a.PinName, - TargetBlockName = a.TargetBlockName, - IsLoopback = a.IsLoopback - }).ToList(), + Arms = cfgStmt.Arms.Select(a => a.Clone()).ToList(), // ToLoopCondReturnTo is a separate field (not in Arms) — copy it explicitly so // ToLoopCond statements retain their loopback target across CFG→BS. ToLoopCondReturnTo = cfgStmt.ToLoopCondReturnTo, diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFGConditionDuplicator.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFGConditionDuplicator.cs index 99ab6c3e..74a6bfec 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFGConditionDuplicator.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFGConditionDuplicator.cs @@ -83,12 +83,7 @@ private static void InsertConditionDuplicates(CFGBlock bodyBlock, List new BranchArm - { - PinName = a.PinName, - TargetBlockName = a.TargetBlockName, - IsLoopback = a.IsLoopback - }).ToList(), + Arms = source.Arms.Select(a => a.Clone()).ToList(), // ToLoopCondReturnTo is a separate field (not in Arms) — copy it explicitly. ToLoopCondReturnTo = source.ToLoopCondReturnTo, Fingerprint = source.Fingerprint, diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/ControlFlowMapping.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/ControlFlowMapping.cs new file mode 100644 index 00000000..987c81af --- /dev/null +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/ControlFlowMapping.cs @@ -0,0 +1,54 @@ +using KitX.Core.Contract.Workflow; +using KitX.Workflow.CFG; + +using static KitX.Workflow.BlockScripting.BlockScriptWellKnown.Functions; + +namespace KitX.Workflow.Conversion; + +/// +/// Centralised bidirectional mapping between the BlockScript layer's +/// and the CFG layer's , +/// plus the canonical function-name string for each control-flow kind. +/// +/// Replaces three duplicated mapping tables that had drifted across the converters: +/// BS2CFGConverter.MapControlTypeToKind / MapControlTypeToFunctionName, +/// BP2CFGConverter's inline switch inside ConvertBlockStatementToCfgStatement, and +/// CFG2BSConverter.KindToControlType. All five control-flow kinds +/// (Branch / Loop / Switch / ToLoopCond / Break) map 1:1; non-control-flow +/// CFGStatementKind values have no FlowControlType counterpart (null). +/// +internal static class ControlFlowMapping +{ + /// FlowControlType → CFGStatementKind. Unknown for unmapped. + public static CFGStatementKind ToKind(FlowControlType type) => type switch + { + FlowControlType.Branch => CFGStatementKind.Branch, + FlowControlType.Loop => CFGStatementKind.Loop, + FlowControlType.Switch => CFGStatementKind.Switch, + FlowControlType.ToLoopCond => CFGStatementKind.ToLoopCond, + FlowControlType.Break => CFGStatementKind.Break, + _ => CFGStatementKind.Unknown + }; + + /// FlowControlType → canonical function-name string (e.g. "Branch"). + public static string ToFunctionName(FlowControlType type) => type switch + { + FlowControlType.Branch => Branch, + FlowControlType.Loop => Loop, + FlowControlType.Switch => Switch, + FlowControlType.ToLoopCond => ToLoopCond, + FlowControlType.Break => Break, + _ => string.Empty + }; + + /// CFGStatementKind → FlowControlType, or null for non-control-flow kinds. + public static FlowControlType? ToControlType(CFGStatementKind kind) => kind switch + { + CFGStatementKind.Branch => FlowControlType.Branch, + CFGStatementKind.Loop => FlowControlType.Loop, + CFGStatementKind.Switch => FlowControlType.Switch, + CFGStatementKind.ToLoopCond => FlowControlType.ToLoopCond, + CFGStatementKind.Break => FlowControlType.Break, + _ => null + }; +} \ No newline at end of file diff --git a/KitX Standard b/KitX Standard index 03f800fe..829afd3f 160000 --- a/KitX Standard +++ b/KitX Standard @@ -1 +1 @@ -Subproject commit 03f800feb2f0a3d1c3335534bb91c90fc59f7ed6 +Subproject commit 829afd3f81e5f55cf376e63bbc63d8a65dc86d39 From dcdfbf6c0bb9160fc3b9b3971a9e3699005256b2 Mon Sep 17 00:00:00 2001 From: StarInk Date: Sat, 20 Jun 2026 03:32:53 +0200 Subject: [PATCH 096/110] =?UTF-8?q?=F0=9F=A7=A9=20Refactor(Workflow):=20?= =?UTF-8?q?=E6=B6=88=E9=99=A4ToLoopCondReturnTo=E8=A1=A5=E4=B8=81=E5=AD=97?= =?UTF-8?q?=E6=AE=B5-KISS=E4=BC=98=E5=8C=96=E9=98=B6=E6=AE=B5L2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 消除 ToLoopCondReturnTo 补丁字段——它一个字段承担两种语义,且是上一轮 为绕开 Arms[0] 冲突 bug 而拆出的独立字段。经核实两种语义可统一/删除: 1. ToLoopCond 语句的唯一目标:原存于独立字段 ToLoopCondReturnTo,现统一进 Arms[0](PinName="Exec", IsLoopback=true),与 Branch/Loop/Switch 一致 ——所有控制流目标全部走 Arms。新增 LoopbackTarget 便利访问器(CFGStatement 与 FlowControlStatement 各一)封装 Arms[0] 读写。 2. Loop 语句记录"自己所属 Loop 块名"的反向引用元数据:经 grep 核实为 死元数据——仅被 BlockStatementExtractor.CreateLoopBlocksForBlock 写入、 被转换器复制、被调试打印,从未被任何控制流逻辑读取(父 loop 关系靠 CFGEdge.Type==LoopBody/LoopbackToCondition 与 CFGBlock.ParentLoopBlockName 解析)。直接删除 CreateLoopBlocksForBlock 方法及其 BlockScriptParser 调用点。 IsLoopback 复活:ToLoopCondFunction.GetOutputArms 的 Exec arm 由 IsLoopback=false 改为 true(文档原本就声明 true,实现却绕过 Arms 走独立字段 导致 IsLoopback 事实死字段)。现 GetEdgeType 的 loopback 分支真正生效。 影响面更新:ToLoopCondFunction(ExtractStatement/OnNodeCreated/EmitStatements/ ToStatement/GetOutputArms)、BP2CFG(ToLoopCond 边构建/CreateToLoopCondStatement/ 复制点)、BS2CFG/CFG2BS/CFGConditionDuplicator(删除复制点)、ControlFlowGraph 与 BlockScriptToBlueprintConverter(调试打印)、FlowControlStatement. RegenerateSourceCode。 验证:dotnet build 0 错误;22 测试 A-V 全 PASS(32 PASS / 0 FAIL), 含 I/K/L 循环测试(ExecutedBlockCount=9, Output=[0,1,2,Done])与 D/H/O4/V 往返测试。 # KitX Workflow KISS 深度重构 L2 阶段:消除 ToLoopCondReturnTo 补丁字段 # 后续 L3+L4-L9 见会话 todo 列表,分阶段推进。 # Date: 2026-06-19 # Author: StarInk --- .../BlockScripting/BlockScriptParser.cs | 7 ------ .../BlockScripting/BlockStatementExtractor.cs | 21 ------------------ .../BuiltinFunctions/ToLoopCondFunction.cs | 10 ++++----- .../KitX.Workflow/CFG/CFGStatement.cs | 18 +++++++++------ .../KitX.Workflow/CFG/ControlFlowGraph.cs | 4 ++-- .../Models/Statements/FlowControlStatement.cs | 22 +++++++++++-------- .../Conversion/BP2CFGConverter.cs | 14 ++++++------ .../Conversion/BS2CFGConverter.cs | 4 +--- .../BlockScriptToBlueprintConverter.cs | 2 +- .../Conversion/CFG2BSConverter.cs | 4 +--- .../Conversion/CFGConditionDuplicator.cs | 3 +-- 11 files changed, 42 insertions(+), 67 deletions(-) diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptParser.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptParser.cs index 7ad034c1..8c833756 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptParser.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptParser.cs @@ -105,13 +105,6 @@ public BlockScriptParseResult Parse(string sourceCode) } script.AllBlocks.Add(blockDef); - - // Create LoopBlocks for blocks containing Loop statements - // NOTE: In new design, CreateLoopBlocksForBlock only sets metadata (no hidden blocks) - if (blockDef.Type == BlockType.MainBlock || blockDef.Type == BlockType.NamedBlock) - { - _extractor.CreateLoopBlocksForBlock(blockDef, script); - } } // Phase 4: Link blocks sequentially diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockStatementExtractor.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockStatementExtractor.cs index 100ed4ee..4ee6549b 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockStatementExtractor.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockStatementExtractor.cs @@ -106,27 +106,6 @@ public BlockDefinition CreateBlockDefinition( return blockDef; } - /// - /// Sets ToLoopCondReturnTo metadata for Loop statements in the block. - /// New design: Loop statements stay in their parent block, no hidden sub-blocks created. - /// - public void CreateLoopBlocksForBlock(BlockDefinition block, BlockScript script) - { - var loopStatements = block.Statements - .OfType() - .Where(fs => fs.ControlType == FlowControlType.Loop) - .ToList(); - - if (loopStatements.Count == 0) return; - - foreach (var loopStmt in loopStatements) - loopStmt.ToLoopCondReturnTo = block.Name; - - Log.Debug("[BlockStatementExtractor] Block '{BlockName}' contains {Count} Loop statement(s), " + - "ToLoopCondReturnTo set to '{BlockName}'", - block.Name, loopStatements.Count, block.Name); - } - /// /// Extracts statements from syntax root into block definition. /// Dispatches each descendant node to a focused helper, keeping the top-level diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ToLoopCondFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ToLoopCondFunction.cs index cfa0ade8..e8baeb08 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ToLoopCondFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ToLoopCondFunction.cs @@ -44,7 +44,7 @@ public class ToLoopCondFunction : IBuiltinFunctionDefinition SourceCode = exprText ?? invoke.ToFullString(), ControlType = FlowControlType.ToLoopCond }; - if (args.Count >= 1) stmt.ToLoopCondReturnTo = ExprUtils.GetStringLiteralValue(args[0].Expression) ?? string.Empty; + if (args.Count >= 1) stmt.LoopbackTarget = ExprUtils.GetStringLiteralValue(args[0].Expression) ?? string.Empty; return stmt; } @@ -52,7 +52,7 @@ public class ToLoopCondFunction : IBuiltinFunctionDefinition public void OnNodeCreated(BlueprintNode node, CFGStatement stmt, PipelineContext context) { - var returnToBlock = stmt.ToLoopCondReturnTo; + var returnToBlock = stmt.LoopbackTarget; if (!string.IsNullOrEmpty(returnToBlock)) { context.DeferredEdges.Add(new DeferredControlFlowEdge @@ -64,7 +64,7 @@ public void OnNodeCreated(BlueprintNode node, CFGStatement stmt, PipelineContext } public List EmitStatements(CFGStatement stmt, CSEmitContext ctx) - => ctx.EmitNextBlockAssignment("ToLoopCond", ctx.Literal(stmt.ToLoopCondReturnTo ?? "")); + => ctx.EmitNextBlockAssignment("ToLoopCond", ctx.Literal(stmt.LoopbackTarget ?? "")); public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) { @@ -87,7 +87,7 @@ public List EmitStatements(CFGStatement stmt, CSEmitContext ctx return new FlowControlStatement { ControlType = FlowControlType.ToLoopCond, - ToLoopCondReturnTo = returnTo, + LoopbackTarget = returnTo, SourceCode = $"NextBlock = ToLoopCond(\"{returnTo}\");", LineNumber = 1 }; @@ -105,7 +105,7 @@ public List EmitStatements(CFGStatement stmt, CSEmitContext ctx } public IEnumerable GetOutputArms() => [ - new() { PinName = "Exec", IsLoopback = false } + new() { PinName = "Exec", IsLoopback = true } ]; } } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/CFG/CFGStatement.cs b/KitX Clients/KitX Workflow/KitX.Workflow/CFG/CFGStatement.cs index defd2ab9..173ae9e7 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/CFG/CFGStatement.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/CFG/CFGStatement.cs @@ -148,14 +148,18 @@ public string? FalseBlockName } /// - /// The ToLoopCond loopback target block name. Kept as a SEPARATE field (not routed through - /// Arms[0]) so that setting it on a Loop/Branch statement — which BlockStatementExtractor - /// .CreateLoopBlocksForBlock does to record the loop's own block as the loopback target — - /// does NOT clobber (Arms[0]). On a ToLoopCond statement this is - /// the sole target; on a Loop/Branch statement it carries the owning loop's condition block - /// for back-edge resolution and is independent of the Branch/Loop arms. + /// The ToLoopCond loopback target — the loop condition block this statement returns to. + /// Unified into [0] (PinName="Exec", IsLoopback=true) so ToLoopCond is + /// treated uniformly with Branch/Loop/Switch: all control-flow targets live in Arms. + /// The former standalone ToLoopCondReturnTo field was a patch over an Arms[0] + /// collision that no longer exists; Loop statements no longer carry this metadata at all + /// (it was dead — never read for control flow, only copied and debug-printed). /// - public string? ToLoopCondReturnTo { get; set; } + public string? LoopbackTarget + { + get => Arms.Count > 0 ? Arms[0].TargetBlockName : null; + set => SetArm(0, "Exec", value, isLoopback: true); + } private void SetArm(int index, string pinName, string? value, bool isLoopback = false) { diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/CFG/ControlFlowGraph.cs b/KitX Clients/KitX Workflow/KitX.Workflow/CFG/ControlFlowGraph.cs index af96a512..0eff51cc 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/CFG/ControlFlowGraph.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/CFG/ControlFlowGraph.cs @@ -79,8 +79,8 @@ public string Dump() sb.AppendLine($" [{stmt.Kind}] {stmt.OriginalExpression}{dup}"); if (!string.IsNullOrEmpty(stmt.TrueBlockName)) sb.AppendLine($" → True=\"{stmt.TrueBlockName}\", False=\"{stmt.FalseBlockName}\""); - if (!string.IsNullOrEmpty(stmt.ToLoopCondReturnTo)) - sb.AppendLine($" → ToLoopCond=\"{stmt.ToLoopCondReturnTo}\""); + if (!string.IsNullOrEmpty(stmt.LoopbackTarget)) + sb.AppendLine($" → ToLoopCond=\"{stmt.LoopbackTarget}\""); if (!string.IsNullOrEmpty(stmt.PubVarTarget)) sb.AppendLine($" PubVarTarget={stmt.PubVarTarget}"); } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/Statements/FlowControlStatement.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/Statements/FlowControlStatement.cs index a8cb66d3..3aefb9fc 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/Statements/FlowControlStatement.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/Statements/FlowControlStatement.cs @@ -49,14 +49,18 @@ public string FalseBlockName } /// - /// The ToLoopCond loopback target block name. Kept as a SEPARATE field (not routed through - /// Arms[0]) so that setting it on a Loop/Branch statement — which BlockStatementExtractor - /// .CreateLoopBlocksForBlock does to record the loop's own block as the loopback target — - /// does NOT clobber (Arms[0]). On a ToLoopCond statement this is - /// the sole target; on a Loop/Branch statement it carries the owning loop's condition block - /// for back-edge resolution and is independent of the Branch/Loop arms. + /// The ToLoopCond loopback target — the loop condition block this statement returns to. + /// Unified into [0] (PinName="Exec", IsLoopback=true) so ToLoopCond is + /// treated uniformly with Branch/Loop/Switch: all control-flow targets live in Arms. + /// The former standalone ToLoopCondReturnTo field was a patch over an Arms[0] + /// collision that no longer exists; Loop statements no longer carry this metadata at all + /// (it was dead — never read for control flow, only copied and debug-printed). /// - public string? ToLoopCondReturnTo { get; set; } + public string? LoopbackTarget + { + get => Arms.Count > 0 ? Arms[0].TargetBlockName : null; + set => SetArm(0, "Exec", value ?? string.Empty, isLoopback: true); + } private void SetArm(int index, string pinName, string value, bool isLoopback = false) { @@ -77,8 +81,8 @@ public void RegenerateSourceCode() { FlowControlType.Branch => $"NextBlock = Branch({ConditionExpression}, \"{TrueBlockName}\", \"{FalseBlockName}\");", FlowControlType.Loop => $"NextBlock = Loop({ConditionExpression}, \"{TrueBlockName}\", \"{FalseBlockName}\");", - FlowControlType.ToLoopCond => !string.IsNullOrEmpty(ToLoopCondReturnTo) - ? $"NextBlock = ToLoopCond(\"{ToLoopCondReturnTo}\");" + FlowControlType.ToLoopCond => !string.IsNullOrEmpty(LoopbackTarget) + ? $"NextBlock = ToLoopCond(\"{LoopbackTarget}\");" : "ToLoopCond();", FlowControlType.Switch => RegenerateSwitchSource(), FlowControlType.Break => "Break();", diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BP2CFGConverter.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BP2CFGConverter.cs index 6fdc1538..17789d06 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BP2CFGConverter.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BP2CFGConverter.cs @@ -573,11 +573,11 @@ private void BuildEdgesFromTopology( // Non-registry control flow handling (fallback) if (lastStmt.Kind == CFGStatementKind.ToLoopCond) { - if (!string.IsNullOrEmpty(lastStmt.ToLoopCondReturnTo)) + if (!string.IsNullOrEmpty(lastStmt.LoopbackTarget)) block.Successors.Add(new CFGEdge { FromBlockName = block.Name, - ToBlockName = lastStmt.ToLoopCondReturnTo, + ToBlockName = lastStmt.LoopbackTarget, Type = CFGEdgeType.LoopbackToCondition }); continue; @@ -895,11 +895,9 @@ private CFGStatement ConvertBlockStatementToCfgStatement(BlockStatement blockStm cfgStmt.FunctionName = null; cfgStmt.ConditionExpression = flow.ConditionExpression; // Copy the full arm list so N-way Switch and any variadic shape survive. + // ToLoopCond's loopback target lives in Arms[0] (IsLoopback=true), so it is + // carried by this clone — no separate field copy needed. cfgStmt.Arms = flow.Arms.Select(a => a.Clone()).ToList(); - // ToLoopCondReturnTo is a separate field (not in Arms) — copy it explicitly so - // ToLoopCond statements (and Loop statements carrying a loopback target set by - // BlockStatementExtractor.CreateLoopBlocksForBlock) retain it across BP→CFG. - cfgStmt.ToLoopCondReturnTo = flow.ToLoopCondReturnTo; cfgStmt.ConditionPubVar = flow.ConditionExpression?.Trim(); break; @@ -964,12 +962,14 @@ private static CFGStatement CreateToLoopCondStatement(string? returnTo) var stmt = new CFGStatement { Kind = CFGStatementKind.ToLoopCond, - ToLoopCondReturnTo = returnTo, OriginalExpression = returnTo != null ? $"NextBlock = ToLoopCond(\"{returnTo}\");" : "NextBlock = ToLoopCond();", SourceLine = 1 }; + // The loopback target is the sole arm (PinName="Exec", IsLoopback=true), unifying + // ToLoopCond with Branch/Loop/Switch: all control-flow targets live in Arms. + stmt.LoopbackTarget = returnTo; return stmt; } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BS2CFGConverter.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BS2CFGConverter.cs index 60cb7c44..adda4fbd 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BS2CFGConverter.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BS2CFGConverter.cs @@ -142,10 +142,8 @@ private List FormatFlowControl(FlowControlStatement flowCtrl, stri ConditionPubVar = condPubVar, ConditionExpression = flowCtrl.ConditionExpression, // Copy the full arm list so N-way Switch and any variadic shape survive. + // ToLoopCond's loopback target lives in Arms[0] (IsLoopback=true), carried by this clone. Arms = flowCtrl.Arms.Select(a => a.Clone()).ToList(), - // ToLoopCondReturnTo is a separate field (not in Arms) — copy it explicitly so - // ToLoopCond statements retain their loopback target across BS→CFG. - ToLoopCondReturnTo = flowCtrl.ToLoopCondReturnTo, OriginalExpression = flowCtrl.SourceCode, SourceLine = flowCtrl.LineNumber }; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BlockScriptToBlueprintConverter.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BlockScriptToBlueprintConverter.cs index 2e989b05..4c959ed7 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BlockScriptToBlueprintConverter.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BlockScriptToBlueprintConverter.cs @@ -177,7 +177,7 @@ public static string DumpFormattedScript(PipelineContext context) $" | PubVarTarget={stmt.PubVarTarget} Func={stmt.FunctionName}" + $" Args=[{args}]" + $" CondPubVar={stmt.ConditionPubVar} True={stmt.TrueBlockName} False={stmt.FalseBlockName}" + - $" ToLoopCondReturnTo={stmt.ToLoopCondReturnTo}{dup}{fp}"); + $" LoopbackTarget={stmt.LoopbackTarget}{dup}{fp}"); } sb.AppendLine(); } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFG2BSConverter.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFG2BSConverter.cs index 27746fe4..5097a2f0 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFG2BSConverter.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFG2BSConverter.cs @@ -117,10 +117,8 @@ public BlockScript Generate(ControlFlowGraph cfg) ControlType = controlType.Value, ConditionExpression = cfgStmt.ConditionExpression ?? string.Empty, // Copy the full arm list so N-way Switch and any variadic shape survive. + // ToLoopCond's loopback target lives in Arms[0] (IsLoopback=true), carried by this clone. Arms = cfgStmt.Arms.Select(a => a.Clone()).ToList(), - // ToLoopCondReturnTo is a separate field (not in Arms) — copy it explicitly so - // ToLoopCond statements retain their loopback target across CFG→BS. - ToLoopCondReturnTo = cfgStmt.ToLoopCondReturnTo, SourceCode = cfgStmt.OriginalExpression, LineNumber = cfgStmt.SourceLine }; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFGConditionDuplicator.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFGConditionDuplicator.cs index 74a6bfec..6fbd677c 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFGConditionDuplicator.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFGConditionDuplicator.cs @@ -83,9 +83,8 @@ private static void InsertConditionDuplicates(CFGBlock bodyBlock, List a.Clone()).ToList(), - // ToLoopCondReturnTo is a separate field (not in Arms) — copy it explicitly. - ToLoopCondReturnTo = source.ToLoopCondReturnTo, Fingerprint = source.Fingerprint, IsLoopConditionDuplication = true }; From f418b90a5ed2cf3e57b0762e1a713df86088b63e Mon Sep 17 00:00:00 2001 From: StarInk Date: Sat, 20 Jun 2026 03:38:29 +0200 Subject: [PATCH 097/110] =?UTF-8?q?=F0=9F=A7=A9=20Refactor(Workflow):=20?= =?UTF-8?q?=E7=BB=9F=E4=B8=80NextBlockName=E4=B8=8ESuccessors-KISS?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=98=B6=E6=AE=B5L3+L4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L3:删除 CFGBlock.NextBlockName 字段(与 Successors 里 Sequential 边重复, 双重真相源),改用 Successors 里的 Sequential 边 + 计算属性 FallThroughTarget 作为唯一真相源。BS 层 BlockDefinition.NextBlockName 保留(该层无 Successors 概念,是 BlockScript 原生表达)。 L4:让 CFGEdgeType.Sequential 真正生效——它现在被 5 个下游消费者经 FallThroughTarget 消费(CFG2BSConverter/CFG2BPConverter/CFG2CSGenerator/ PipelineAssembler/BP2CFGConverter.ResolveMissingNextBlockNames)。其余控制流 边类型(BranchTrue/BranchFalse/LoopExit/Break/Switch)经核实其控制流解析由 语句 Arms + builtin EmitStatements 驱动,添加 edge.Type 消费者会复制可测逻辑 而非修复破损逻辑,故保持为描述性元数据并在 CFGEdgeType 文档明确此设计决策。 L3 改造点(按安全前置清单): 1. BS2CFGConverter.FormatBlock:原只设 NextBlockName 不建 Successors 边 (BS→CFG 路径不同步,是删除前唯一硬阻塞)。现改为对非控制流结尾且 NextBlockName 非空的块补建 Sequential Successors 边。 2. CFGBlock:删 NextBlockName 字段,新增 FallThroughTarget 计算属性 (Successors 里 Sequential 边的 ToBlockName)。 3. CFG2BSConverter/CFG2BPConverter/CFG2CSGenerator/PipelineAssembler: 改读 FallThroughTarget。 4. BP2CFGConverter.BuildBlocksFromScopes:删 NextBlockName=scope.NextBlockName 初始化;BuildEdgesFromTopology 改从 scope.NextBlockName 取 fall-through 建边。 5. BP2CFGConverter.ResolveMissingNextBlockNames:删 block.NextBlockName 回填 (661-667 已建 Sequential 边),改用 FallThroughTarget 判重。 6. ControlFlowGraph/BlockScriptToBlueprintConverter/CSCompiler/Program.cs: 调试输出改读 FallThroughTarget。 风险控制:循环 back-edge(LoopbackToCondition)与 Break 边本就走 Successors 且块 EndsWithControlFlow=true,NextBlockName 必为 null,删除零影响。 BlockScopes 路径与 Topology 路径本已双写同步,仅 BS→CFG 路径需先补边。 验证:dotnet build 0 错误;22 测试 A-V 全 PASS(32 PASS / 0 FAIL), 含 D/H/I/O4/V 往返测试与 I/K/L 循环测试(ExecutedBlockCount=9)。 # KitX Workflow KISS 深度重构 L3+L4 阶段:统一 NextBlockName 与 Successors # 后续 L5+L6-L9 见会话 todo 列表,分阶段推进。 # Date: 2026-06-19 # Author: StarInk --- .../KitX.Core.BluePrint.Test/Program.cs | 2 +- .../BlockScripting/CSCompiler.cs | 4 ++-- .../KitX.Workflow/CFG/CFGBlock.cs | 15 +++++++++++---- .../KitX.Workflow/CFG/CFGEdge.cs | 19 +++++++++++++++++++ .../KitX.Workflow/CFG/ControlFlowGraph.cs | 2 +- .../Conversion/BP2CFGConverter.cs | 15 +++++++++------ .../Conversion/BS2CFGConverter.cs | 18 +++++++++++++++++- .../BlockScriptToBlueprintConverter.cs | 2 +- .../Conversion/CFG2BPConverter.cs | 6 ++++-- .../Conversion/CFG2BSConverter.cs | 5 ++++- .../Conversion/CFG2CSGenerator.cs | 8 +++++--- .../Conversion/PipelineAssembler.cs | 2 +- 12 files changed, 75 insertions(+), 23 deletions(-) diff --git a/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs b/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs index 0522d7f9..a986e189 100644 --- a/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs +++ b/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs @@ -351,7 +351,7 @@ private static void RunTest(BlockScriptToBlueprintConverter converter, var formatted = converter.LastContext.FormattedScript; foreach (var block in formatted.Blocks) { - Console.WriteLine($" #Block {block.Name} (NextBlock={block.NextBlockName ?? "null"})"); + Console.WriteLine($" #Block {block.Name} (FallThrough={block.FallThroughTarget ?? "null"})"); foreach (var stmt in block.Statements) { var args = stmt.Arguments != null ? string.Join(", ", stmt.Arguments) : ""; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/CSCompiler.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/CSCompiler.cs index 1a164852..0bbbee4e 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/CSCompiler.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/CSCompiler.cs @@ -232,8 +232,8 @@ public void ClearCache() formattedScript.Blocks.Count, formattedScript.MainBlockName); foreach (var block in formattedScript.Blocks) { - Log.Debug("[CSCompiler] Block '{Name}' → NextBlock={Next}, Statements={Count}", - block.Name, block.NextBlockName, block.Statements.Count); + Log.Debug("[CSCompiler] Block '{Name}' → FallThrough={Next}, Statements={Count}", + block.Name, block.FallThroughTarget, block.Statements.Count); foreach (var stmt in block.Statements) Log.Debug("[CSCompiler] Kind={Kind} PubVar={PubVar} Fn={Fn} Args=[{Args}] CondPubVar={Cond}", stmt.Kind, stmt.PubVarTarget, stmt.FunctionName, diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/CFG/CFGBlock.cs b/KitX Clients/KitX Workflow/KitX.Workflow/CFG/CFGBlock.cs index 0bb086a3..66697ed5 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/CFG/CFGBlock.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/CFG/CFGBlock.cs @@ -48,15 +48,22 @@ public class CFGBlock /// /// Typed edges from this block to its successors. /// A block can have multiple successors (e.g., BranchHeader → True/False). + /// Sequential fall-through is represented as a + /// edge here — the single source of truth for "what runs next" — instead of a + /// parallel NextBlockName field that could drift out of sync. /// public List Successors { get; set; } = []; /// - /// For sequential fall-through (NextBlock = "..."), the name of the next block. - /// Null if this block ends with a control flow statement (Branch/Loop/ToLoopCond/Break) - /// or has no successor. + /// The sequential fall-through target: the ToBlockName of the + /// edge in , or null. + /// Equivalent to the former standalone NextBlockName field, now derived from + /// so there is a single source of truth. Non-null only for + /// blocks that do not (control-flow blocks have no + /// Sequential edge). /// - public string? NextBlockName { get; set; } + public string? FallThroughTarget => + Successors.FirstOrDefault(e => e.Type == CFGEdgeType.Sequential)?.ToBlockName; /// /// For loop body blocks, the name of the parent loop header block. diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/CFG/CFGEdge.cs b/KitX Clients/KitX Workflow/KitX.Workflow/CFG/CFGEdge.cs index 8a99bdbe..a945fc4b 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/CFG/CFGEdge.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/CFG/CFGEdge.cs @@ -3,12 +3,31 @@ namespace KitX.Workflow.CFG; /// /// Type of a control flow edge between blocks. Making edge semantics /// explicit eliminates the need for heuristic-based control flow resolution. +/// +/// Consumption model. The active consumers are: +/// +/// — read via +/// by CFG2BS / CFG2BP / CFG2CS / PipelineAssembler (the sole fall-through truth source, +/// replacing the former parallel NextBlockName field). +/// / — read by +/// BP2CFGConverter.SetParentLoopReferences and CFGConditionDuplicator to resolve the +/// parent loop of a body block and to duplicate loop conditions before back-edges. +/// +/// , , , +/// , are produced by GetEdgeType from +/// (statement Kind, arm PinName) and serve as descriptive edge metadata. Their +/// control-flow resolution is driven by the statement's Arms + the builtin's +/// EmitStatements (which read arms directly), so a parallel edge.Type-driven +/// consumer would duplicate that resolution rather than replace a broken one. They are +/// kept for observability and future edge-walking consumers; they are not "dead" in the +/// sense of being wrong, only not-yet-required as an authority. /// public enum CFGEdgeType { /// /// Sequential fall-through (NextBlock assignment). /// The current block ends normally and transfers to the next block. + /// Consumed via . /// Sequential, diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/CFG/ControlFlowGraph.cs b/KitX Clients/KitX Workflow/KitX.Workflow/CFG/ControlFlowGraph.cs index 0eff51cc..3e40e25a 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/CFG/ControlFlowGraph.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/CFG/ControlFlowGraph.cs @@ -72,7 +72,7 @@ public string Dump() foreach (var block in Blocks) { - sb.AppendLine($" ── Block \"{block.Name}\" (Type={block.Type}, IsMain={block.IsMainBlock}, NextBlock={block.NextBlockName ?? "null"}, ParentLoop={block.ParentLoopBlockName ?? "null"})"); + sb.AppendLine($" ── Block \"{block.Name}\" (Type={block.Type}, IsMain={block.IsMainBlock}, FallThrough={block.FallThroughTarget ?? "null"}, ParentLoop={block.ParentLoopBlockName ?? "null"})"); foreach (var stmt in block.Statements) { var dup = stmt.IsLoopConditionDuplication ? " [COND_DUP]" : ""; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BP2CFGConverter.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BP2CFGConverter.cs index 17789d06..473927d9 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BP2CFGConverter.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BP2CFGConverter.cs @@ -297,7 +297,6 @@ private void BuildBlocksFromScopes( { Name = scope.Name, Type = scope.IsMainBlock ? CFGBlockType.Entry : CFGBlockType.Basic, - NextBlockName = scope.NextBlockName, }; foreach (var nodeId in scope.NodeIds) @@ -594,13 +593,17 @@ private void BuildEdgesFromTopology( continue; } - // Sequential fall-through: derive NextBlockName from the block or from exec connections - if (!string.IsNullOrEmpty(block.NextBlockName)) + // Sequential fall-through: derive the target from the block's BlueprintBlockScope + // (for the BlockScopes path) — the single source of truth is now the Sequential edge + // built here, not a parallel CFGBlock.NextBlockName field. + var scope = blueprint.BlockScopes.FirstOrDefault(s => s.Name == block.Name); + var fallThrough = scope?.NextBlockName; + if (!string.IsNullOrEmpty(fallThrough)) { block.Successors.Add(new CFGEdge { FromBlockName = block.Name, - ToBlockName = block.NextBlockName, + ToBlockName = fallThrough, Type = CFGEdgeType.Sequential, PinName = Exec }); @@ -623,7 +626,7 @@ private static void ResolveMissingNextBlockNames( { foreach (var block in cfg.Blocks) { - if (!string.IsNullOrEmpty(block.NextBlockName)) continue; + if (block.FallThroughTarget != null) continue; // Already has a Sequential edge if (block.EndsWithControlFlow) continue; if (block.Successors.Count > 0) continue; // Already has edges @@ -657,7 +660,7 @@ private static void ResolveMissingNextBlockNames( var targetBlockName = FindBlockContainingNode(cfg, targetNode.Id, blueprint); if (targetBlockName != null) { - block.NextBlockName = targetBlockName; + // Single source of truth: build the Sequential edge (no parallel NextBlockName field). block.Successors.Add(new CFGEdge { FromBlockName = block.Name, diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BS2CFGConverter.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BS2CFGConverter.cs index adda4fbd..6ab5d2f7 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BS2CFGConverter.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BS2CFGConverter.cs @@ -67,7 +67,7 @@ public ControlFlowGraph Format(BlockScript script, PipelineContext context) private CFGBlock FormatBlock(BlockDefinition blockDef, PipelineContext context) { - var result = new CFGBlock { Name = blockDef.Name, NextBlockName = blockDef.NextBlockName }; + var result = new CFGBlock { Name = blockDef.Name }; // BlockScript §6: a flow-control statement (Branch/Loop/ToLoopCond/Break) terminates // the block; any statement after it is unreachable dead code. Track the terminator and // warn (non-fatal) on subsequent statements instead of silently formatting them. @@ -88,6 +88,22 @@ private CFGBlock FormatBlock(BlockDefinition blockDef, PipelineContext context) if (stmt is FlowControlStatement) seenTerminator = true; } + + // Sequential fall-through: represent BlockDefinition.NextBlockName as a Sequential edge + // in Successors (single source of truth) instead of a parallel CFGBlock.NextBlockName + // field. Only non-control-flow blocks fall through; control-flow blocks already carry + // their Branch/Loop/Switch/ToLoopCond/Break targets as typed Successors edges elsewhere. + if (!result.EndsWithControlFlow && !string.IsNullOrEmpty(blockDef.NextBlockName)) + { + result.Successors.Add(new CFGEdge + { + FromBlockName = result.Name, + ToBlockName = blockDef.NextBlockName, + Type = CFGEdgeType.Sequential, + PinName = "Exec" + }); + } + return result; } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BlockScriptToBlueprintConverter.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BlockScriptToBlueprintConverter.cs index 4c959ed7..0d51fc11 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BlockScriptToBlueprintConverter.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BlockScriptToBlueprintConverter.cs @@ -167,7 +167,7 @@ public static string DumpFormattedScript(PipelineContext context) var sb = new System.Text.StringBuilder(); foreach (var block in context.FormattedScript.Blocks) { - sb.AppendLine($"#Block {block.Name} (NextBlock={block.NextBlockName ?? "null"})"); + sb.AppendLine($"#Block {block.Name} (FallThrough={block.FallThroughTarget ?? "null"})"); foreach (var stmt in block.Statements) { var dup = stmt.IsLoopConditionDuplication ? " [LoopCondDup]" : ""; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFG2BPConverter.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFG2BPConverter.cs index b7dc62fb..5904b785 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFG2BPConverter.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFG2BPConverter.cs @@ -99,8 +99,10 @@ private void ProcessBlock(CFGBlock block, PipelineContext context) _blockLastStmtId[block.Name] = prevStmtId ?? ""; } - if (!string.IsNullOrEmpty(block.NextBlockName)) - context.BlockNextBlock[block.Name] = block.NextBlockName; + // Sequential fall-through target now derived from the CFG's Sequential Successors edge + // (single source of truth) rather than a parallel NextBlockName field. + if (!string.IsNullOrEmpty(block.FallThroughTarget)) + context.BlockNextBlock[block.Name] = block.FallThroughTarget; context.BlockEndsWithFlowCtrl[block.Name] = endsWithFlowCtrl; } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFG2BSConverter.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFG2BSConverter.cs index 5097a2f0..4e6543a4 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFG2BSConverter.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFG2BSConverter.cs @@ -78,7 +78,10 @@ public BlockScript Generate(ControlFlowGraph cfg) { Type = cfgBlock.IsMainBlock ? BlockType.MainBlock : BlockType.NamedBlock, Name = cfgBlock.Name, - NextBlockName = cfgBlock.NextBlockName + // BS-layer BlockDefinition.NextBlockName is retained as the BlockScript native + // expression of fall-through; its value is now derived from the CFG's Sequential + // Successors edge (single source of truth) rather than a parallel field. + NextBlockName = cfgBlock.FallThroughTarget }; foreach (var cfgStmt in cfgBlock.Statements) diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFG2CSGenerator.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFG2CSGenerator.cs index 30b76563..f3d481d9 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFG2CSGenerator.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFG2CSGenerator.cs @@ -631,14 +631,16 @@ internal static SwitchSectionSyntax GenerateFormattedBlockCase( } } - // Auto-complete NextBlock if block has NextBlockName and no explicit assignment - if (!hasNextBlockAssignment && !string.IsNullOrEmpty(block.NextBlockName)) + // Auto-complete NextBlock if block has a sequential fall-through target and no explicit assignment. + // The fall-through target is now derived from the CFG's Sequential Successors edge. + var fallThrough = block.FallThroughTarget; + if (!hasNextBlockAssignment && !string.IsNullOrEmpty(fallThrough)) { caseStatements.Add(ExpressionStatement( AssignmentExpression(SyntaxKind.SimpleAssignmentExpression, MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, IdentifierName("G"), IdentifierName("NextBlock")), - LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(block.NextBlockName))))); + LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(fallThrough))))); } if (!caseStatements.Any(s => s is BreakStatementSyntax or ReturnStatementSyntax)) diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/PipelineAssembler.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/PipelineAssembler.cs index e691ea41..8dc1cda8 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/PipelineAssembler.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/PipelineAssembler.cs @@ -191,7 +191,7 @@ private void BuildBlockScopes(KitX.Core.Contract.Workflow.Blueprint bp, Pipeline { Name = block.Name, IsMainBlock = block.Name == mainBlockName, - NextBlockName = block.NextBlockName, + NextBlockName = block.FallThroughTarget, NodeIds = nodeIds, }); } From 39123a26bb844e8d1c3092acc0577238f1ece2bd Mon Sep 17 00:00:00 2001 From: StarInk Date: Sat, 20 Jun 2026 06:12:30 +0200 Subject: [PATCH 098/110] =?UTF-8?q?=F0=9F=A7=A9=20Refactor(Workflow):=20?= =?UTF-8?q?=E5=B5=8C=E5=85=A5ControlFlowArms=E5=8E=BB=E9=87=8D-KISS?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=98=B6=E6=AE=B5L5+L6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L5(高收益子集):CFGStatement 与 FlowControlStatement 各嵌入一个 ControlFlowArms 实例(子模块 da48fc4),把 Arms/TrueBlockName/FalseBlockName/ LoopbackTarget/SetArm 的实现委托给它,消除两层约 80 行重复 + 一个隐藏的 null-vs-string.Empty 不一致。公开访问面(stmt.Arms / flow.TrueBlockName / ...) 完全不变,30+ 调用点零改动。 L6(拆 CFGStatement 继承子类 + 删 FlowControlStatement)经深挖评估后跳过: - 拆继承子类:100+ 访问点需改,FunctionName/Arguments 跨界(表达式类与控制流 类都持有)无法干净归属单一子类,类型模式匹配不比现有 Kind 枚举 switch 更 KISS。扁平 union 未造成实际 bug(所有 Kind 专属字段访问都在已知 Kind 上下文)。 - 删 FlowControlStatement:删除后要么制造 ExpressionStatement 新扁平 union, 要么让 BlockScriptSerializer 跨 BS 抽象边界,约 20 处访问点改写,收益仅省 EndsWithControlFlow 一处类型判断。FlowControlStatement 在抽 ControlFlowArms 后已足够薄,作为 BS 层控制流语句显式类型仍有价值。 这是"先知根本需求再审视代码"方法的应用:深挖后得出"不大改反而更 KISS", 不机械执行计划每一项,只做代价/收益比为正的改造。 改造点: - CFGStatement.cs:删 Arms/TrueBlockName/FalseBlockName/LoopbackTarget/SetArm 重复实现,改为委托 _controlFlowArms 私有字段。 - FlowControlStatement.cs:同样委托 _controlFlowArms;TrueBlockName/FalseBlockName 保留 string 返回类型(?? string.Empty)维持原非空契约。 验证:dotnet build 0 错误;22 测试 A-V 全 PASS(32 PASS / 0 FAIL), 含 D/H/I/O4/V 往返测试与 I/K/L 循环测试(ExecutedBlockCount=9)。 # KitX Workflow KISS 深度重构 L5+L6 阶段:抽取共享值对象(L5 有效子集) # L6 拆子类/删 FlowControlStatement 经评估跳过(代价大于收益) # 后续 L7-L9 见会话 todo 列表,分阶段推进。 # Date: 2026-06-19 # Author: StarInk --- .../KitX.Workflow/CFG/CFGStatement.cs | 38 +++++++-------- .../Models/Statements/FlowControlStatement.cs | 47 +++++++++---------- KitX Standard | 2 +- 3 files changed, 42 insertions(+), 45 deletions(-) diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/CFG/CFGStatement.cs b/KitX Clients/KitX Workflow/KitX.Workflow/CFG/CFGStatement.cs index 173ae9e7..e894e4e9 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/CFG/CFGStatement.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/CFG/CFGStatement.cs @@ -126,48 +126,48 @@ public class CFGStatement /// public string? ConditionPubVar { get; set; } + /// + /// Shared control-flow arms model (Arms / TrueBlockName / FalseBlockName / LoopbackTarget). + /// Embedded once here instead of duplicating the accessors and SetArm across CFGStatement + /// and FlowControlStatement. The delegating properties below keep the public surface + /// (stmt.Arms, stmt.TrueBlockName, ...) unchanged. + /// + private readonly ControlFlowArms _controlFlowArms = new(); + /// /// Outgoing arms of this control-flow statement. Generalised model replacing the former /// fixed TrueBlockName/FalseBlockName/ToLoopCondReturnTo triple. /// See for the per-arm layout of each control-flow kind. /// - public List Arms { get; set; } = []; + public List Arms + { + get => _controlFlowArms.Arms; + set => _controlFlowArms.Arms = value; + } /// Convenience accessor: the true-branch / loop-body target (Arms[0]). public string? TrueBlockName { - get => Arms.Count > 0 ? Arms[0].TargetBlockName : null; - set => SetArm(0, "True", value); + get => _controlFlowArms.TrueBlockName; + set => _controlFlowArms.TrueBlockName = value; } /// Convenience accessor: the false-branch / loop-exit target (Arms[1]). public string? FalseBlockName { - get => Arms.Count > 1 ? Arms[1].TargetBlockName : null; - set => SetArm(1, "False", value); + get => _controlFlowArms.FalseBlockName; + set => _controlFlowArms.FalseBlockName = value; } /// /// The ToLoopCond loopback target — the loop condition block this statement returns to. /// Unified into [0] (PinName="Exec", IsLoopback=true) so ToLoopCond is /// treated uniformly with Branch/Loop/Switch: all control-flow targets live in Arms. - /// The former standalone ToLoopCondReturnTo field was a patch over an Arms[0] - /// collision that no longer exists; Loop statements no longer carry this metadata at all - /// (it was dead — never read for control flow, only copied and debug-printed). /// public string? LoopbackTarget { - get => Arms.Count > 0 ? Arms[0].TargetBlockName : null; - set => SetArm(0, "Exec", value, isLoopback: true); - } - - private void SetArm(int index, string pinName, string? value, bool isLoopback = false) - { - while (Arms.Count <= index) - Arms.Add(new BranchArm()); - Arms[index].PinName = pinName; - Arms[index].TargetBlockName = value ?? string.Empty; - Arms[index].IsLoopback = isLoopback; + get => _controlFlowArms.LoopbackTarget; + set => _controlFlowArms.LoopbackTarget = value; } // --- Metadata --- diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/Statements/FlowControlStatement.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/Statements/FlowControlStatement.cs index 3aefb9fc..cc4e5a8d 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/Statements/FlowControlStatement.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/Statements/FlowControlStatement.cs @@ -17,58 +17,55 @@ public class FlowControlStatement : BlockStatement /// public string ConditionExpression { get; set; } = string.Empty; + /// + /// Shared control-flow arms model (Arms / TrueBlockName / FalseBlockName / LoopbackTarget). + /// Embedded once here instead of duplicating the accessors and SetArm across + /// FlowControlStatement and CFGStatement. The delegating properties below keep the public + /// surface (flow.Arms, flow.TrueBlockName, ...) unchanged. + /// + private readonly ControlFlowArms _controlFlowArms = new(); + /// /// Outgoing arms of this control-flow statement. Replaces the former fixed /// TrueBlockName/FalseBlockName/ToLoopCondReturnTo triple. - /// - /// Branch: ["True", "False"] - /// Loop: ["LoopBody", "LoopEnd"] - /// ToLoopCond: ["Exec" with =true] - /// Switch: ["Default", "0", "1", ... , "N-1"] - /// + /// See for the per-arm layout of each control-flow kind. /// - public List Arms { get; set; } = []; + public List Arms + { + get => _controlFlowArms.Arms; + set => _controlFlowArms.Arms = value; + } /// /// Convenience: the true-branch target (Arms[0].TargetBlockName for Branch/Loop). /// Kept for readability at call sites that conceptually deal with a two-way branch. + /// Returns string.Empty when no arm is set (non-null, for SourceCode safety). /// public string TrueBlockName { - get => Arms.Count > 0 ? Arms[0].TargetBlockName : string.Empty; - set => SetArm(0, "True", value); + get => _controlFlowArms.TrueBlockName ?? string.Empty; + set => _controlFlowArms.TrueBlockName = value; } /// /// Convenience: the false-branch target (Arms[1].TargetBlockName for Branch/Loop). + /// Returns string.Empty when no arm is set (non-null, for SourceCode safety). /// public string FalseBlockName { - get => Arms.Count > 1 ? Arms[1].TargetBlockName : string.Empty; - set => SetArm(1, "False", value); + get => _controlFlowArms.FalseBlockName ?? string.Empty; + set => _controlFlowArms.FalseBlockName = value; } /// /// The ToLoopCond loopback target — the loop condition block this statement returns to. /// Unified into [0] (PinName="Exec", IsLoopback=true) so ToLoopCond is /// treated uniformly with Branch/Loop/Switch: all control-flow targets live in Arms. - /// The former standalone ToLoopCondReturnTo field was a patch over an Arms[0] - /// collision that no longer exists; Loop statements no longer carry this metadata at all - /// (it was dead — never read for control flow, only copied and debug-printed). /// public string? LoopbackTarget { - get => Arms.Count > 0 ? Arms[0].TargetBlockName : null; - set => SetArm(0, "Exec", value ?? string.Empty, isLoopback: true); - } - - private void SetArm(int index, string pinName, string value, bool isLoopback = false) - { - while (Arms.Count <= index) - Arms.Add(new BranchArm()); - Arms[index].PinName = pinName; - Arms[index].TargetBlockName = value; - Arms[index].IsLoopback = isLoopback; + get => _controlFlowArms.LoopbackTarget; + set => _controlFlowArms.LoopbackTarget = value; } /// diff --git a/KitX Standard b/KitX Standard index 829afd3f..da48fc4c 160000 --- a/KitX Standard +++ b/KitX Standard @@ -1 +1 @@ -Subproject commit 829afd3f81e5f55cf376e63bbc63d8a65dc86d39 +Subproject commit da48fc4cb063dc99760b90cbe590b3f6a148016a From 76e7e3f9466b1ba648ead18eed968bc5ca853224 Mon Sep 17 00:00:00 2001 From: StarInk Date: Sat, 20 Jun 2026 06:26:27 +0200 Subject: [PATCH 099/110] =?UTF-8?q?=F0=9F=A7=A9=20Refactor(Workflow):=20?= =?UTF-8?q?=E5=88=A0=E9=99=A4=E9=80=82=E9=85=8D=E5=99=A8=E5=B1=82+Descript?= =?UTF-8?q?or=E6=94=B6=E6=95=9B-KISS=E4=BC=98=E5=8C=96=E9=98=B6=E6=AE=B5L7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L7 有效子集(经深挖评估,只做代价/收益比为正的改动): 项2 必做:删除 INodeExportStrategy + BuiltinFunctionExportStrategyAdapter。 - Adapter 是 IBuiltinFunctionDefinition 的严格子集(只转发 ToStatement/ GetOutputArms/StatementKind/AutoSynthesizePubVar/IsControlFlow,全部已在 IBuiltinFunctionDefinition 接口内),且 dispatcher 还要 is Adapter 特判。 - 删除 1 接口 + 1 适配器类(44 行),BlueprintToBlockScriptConverter 与 BP2CFGConverter 直接注入 Dictionary, 4 处 is BuiltinFunctionExportStrategyAdapter 特判 + _strategies 死字典全部 消除。DI 注册由 foreach 注册 Adapter 改为注册 AllDefinitions。 - INodeExportStrategy.cs 拆分:保留 INodeExportHelper + OutputArmDescriptor (被 26 个 builtin 使用),移至 INodeExportHelper.cs;删 INodeExportStrategy 接口。 项4a 必做:布局字段收敛为 NodeDescriptor。 - IBuiltinFunctionDefinition 新增 Descriptor 默认实现(从 InputPins/OutputPins/ DisplayName/InputVariadic/OutputVariadic 组装),NodeRegistry 直接用 def.Descriptor, 消除原本 5 字段手动重组。26 个 builtin 实现零改动(继续提供单个成员, Descriptor 默认实现组装)。 - NodeWidth/NodeHeight 确认死成员(0 消费者,LayoutService 用节点实例尺寸), 接口保留以免破坏 26 个实现声明,但标注已弃用。 跳过项(经评估代价大于收益,同 L5+L6 跳过拆子类的判断): - 项1 6 固定子类下沉:CallNode.PluginName/ExtraArguments/ConstNode.ConstValue 等 是实例级运行时数据,与 IBuiltinFunctionDefinition(按函数名注册的静态描述符) 语义不匹配;List 无法进 string 字典;JSON 多态序列化依赖子类身份。 20+ 访问点跨界 Dashboard/Workflow,净收益为负。 - 项3 NodeRegistry 单一注册:依赖项1 才成立;_typeMap+_functionRegistry 是合理双维度。 - 项4b IsBlockTerminator 派生:6 个 terminator 无法用单一规则从 StatementKind 派生。 - 项4c LowerToCFG/ExtractStatement 合并:输入类型/阶段/输出/覆写者全不同,正交关注点。 验证:dotnet build Workflow + Test + Dashboard 均 0 错误; 22 测试 A-V 全 PASS(32 PASS / 0 FAIL),含 D/H/I/O4/V 往返与 I/K/L 循环。 # KitX Workflow KISS 深度重构 L7 阶段:删适配器层 + Descriptor 收敛 # 后续 L8-L9 见会话 todo 列表,分阶段推进。 # Date: 2026-06-19 # Author: StarInk --- .../IBuiltinFunctionDefinition.cs | 14 ++++- .../KitX.Workflow/Blueprint/NodeRegistry.cs | 9 +-- ...ExportStrategy.cs => INodeExportHelper.cs} | 44 ++------------ .../Conversion/BP2CFGConverter.cs | 59 ++++++------------- .../BlueprintToBlockScriptConverter.cs | 31 ++++------ .../BuiltinFunctionExportStrategyAdapter.cs | 43 -------------- .../KitX.Workflow/Conversion/CFGPipeline.cs | 5 +- .../Hosting/ServiceCollectionExtensions.cs | 10 ++-- 8 files changed, 56 insertions(+), 159 deletions(-) rename KitX Clients/KitX Workflow/KitX.Workflow/Contract/{INodeExportStrategy.cs => INodeExportHelper.cs} (53%) delete mode 100644 KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BuiltinFunctionExportStrategyAdapter.cs diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/IBuiltinFunctionDefinition.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/IBuiltinFunctionDefinition.cs index 7cd12406..1e8a71c5 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/IBuiltinFunctionDefinition.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/IBuiltinFunctionDefinition.cs @@ -55,10 +55,20 @@ public interface IBuiltinFunctionDefinition // ─── 节点布局 ─────────────────────────────────── - /// 蓝图节点宽度 + /// + /// The canonical node layout descriptor, assembled from , + /// , , and + /// . The default implementation builds it on demand so the 26 + /// builtin implementations need not each declare a Descriptor — they keep providing the + /// individual members and consumes this single property, + /// eliminating the per-node manual reassembly that used to live there. + /// + NodeDescriptor Descriptor => new(InputPins, OutputPins, DisplayName, InputVariadic, OutputVariadic); + + /// 蓝图节点宽度(已不再被任何消费者读取;LayoutService 用节点实例 BlueprintNode.Width/Height。保留以免破坏 26 个实现。) double NodeWidth { get; } - /// 蓝图节点高度 + /// 蓝图节点高度(同 NodeWidth,已不再被读取。) double NodeHeight { get; } /// 输入引脚描述 diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Blueprint/NodeRegistry.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Blueprint/NodeRegistry.cs index d87cf4d1..08d621af 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Blueprint/NodeRegistry.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Blueprint/NodeRegistry.cs @@ -86,12 +86,9 @@ public BlueprintNode CreateBuiltinFunctionNode(string functionName) Name = def.DisplayName }; - var descriptor = new NodeDescriptor( - def.InputPins, def.OutputPins, - def.DisplayName, - def.InputVariadic, - def.OutputVariadic - ); + // Descriptor is assembled once by IBuiltinFunctionDefinition.Descriptor (default impl), + // so this site no longer manually reassembles InputPins/OutputPins/DisplayName/variadics. + var descriptor = def.Descriptor; node.SetDescriptor(descriptor); foreach (var pd in descriptor.InputPins) diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/INodeExportStrategy.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Contract/INodeExportHelper.cs similarity index 53% rename from KitX Clients/KitX Workflow/KitX.Workflow/Contract/INodeExportStrategy.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Contract/INodeExportHelper.cs index f074311f..a79dea1b 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/INodeExportStrategy.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Contract/INodeExportHelper.cs @@ -5,9 +5,8 @@ namespace KitX.Workflow.Contract; using ContractWorkflow = KitX.Core.Contract.Workflow; /// -/// Helper service providing data resolution utilities for node export strategies. -/// Implemented by BlueprintToBlockScriptConverter. -/// Internal to the workflow pipeline. +/// Helper service providing data resolution utilities for node export. +/// Implemented by NodeExportHelper. Internal to the workflow pipeline. /// public interface INodeExportHelper { @@ -40,40 +39,9 @@ public interface INodeExportHelper } /// -/// Strategy for converting a specific node type to a BlockScript statement. -/// Each node type that participates in reverse conversion provides an implementation, -/// eliminating the need for switch-based dispatch in the converter. -/// Internal to the workflow pipeline. -/// -public interface INodeExportStrategy -{ - /// - /// The node type this strategy handles. - /// - ContractWorkflow.BlueprintNodeType NodeType { get; } - - /// - /// Whether this node type represents a control flow construct (Branch, Loop, etc.). - /// Used by the converter to determine main flow termination and sub-graph processing. - /// - bool IsControlFlow { get; } - - /// - /// Converts the node to a BlockScript statement, or null if the node should be skipped. - /// - KitX.Workflow.Contract.Models.BlockStatement? ToStatement(ContractWorkflow.BlueprintNode node, INodeExportHelper helper); - - /// - /// For control flow nodes: returns the output arm configuration. - /// Each arm defines an output pin name and whether it represents a loopback. - /// Non-control-flow strategies return an empty collection. - /// - IEnumerable GetOutputArms(ContractWorkflow.BlueprintNode node); -} - -/// -/// Describes a single output arm of a control flow node (e.g., Branch's True/False, Loop's LoopBody/LoopEnd). -/// Internal to the workflow pipeline. +/// Describes a single output arm of a control flow node (e.g., Branch's True/False, Loop's +/// LoopBody/LoopEnd). Internal to the workflow pipeline. Returned by +/// . /// public struct OutputArmDescriptor { @@ -86,4 +54,4 @@ public struct OutputArmDescriptor /// Whether this arm loops back to a parent node (LoopBody loops back to the Loop node) /// public bool IsLoopback { get; set; } -} +} \ No newline at end of file diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BP2CFGConverter.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BP2CFGConverter.cs index 473927d9..c2634032 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BP2CFGConverter.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BP2CFGConverter.cs @@ -20,16 +20,13 @@ namespace KitX.Workflow.Conversion; /// internal class BP2CFGConverter { - private readonly Dictionary _strategies; - private readonly Dictionary _builtinFunctionStrategies; + private readonly Dictionary _builtinFunctionStrategies; private readonly NodeExportHelper _exportHelper; public BP2CFGConverter( - Dictionary strategies, - Dictionary builtinFunctionStrategies, + Dictionary builtinFunctionStrategies, NodeExportHelper exportHelper) { - _strategies = strategies; _builtinFunctionStrategies = builtinFunctionStrategies; _exportHelper = exportHelper; } @@ -213,9 +210,8 @@ private void AnalyzeConnections( if (sourceNode.NodeType is BlueprintNodeType.Call or BlueprintNodeType.CallHelper || (sourceNode is BuiltinFunctionNode bfn - && _builtinFunctionStrategies.TryGetValue(bfn.FunctionName, out var strat) - && strat is BuiltinFunctionExportStrategyAdapter pubVarAdapter - && pubVarAdapter.AutoSynthesizePubVar)) + && _builtinFunctionStrategies.TryGetValue(bfn.FunctionName, out var def) + && def.AutoSynthesizePubVar)) { var sourcePin = sourceNode.GetPinById(conn.SourcePinId); if (sourcePin == null) continue; @@ -468,11 +464,7 @@ private void ProcessControlFlowSubGraph( if (cfNode is BuiltinFunctionNode bfn && _builtinFunctionStrategies.TryGetValue(bfn.FunctionName, out var bfStrat)) { - arms = bfStrat.GetOutputArms(cfNode); - } - else if (_strategies.TryGetValue(cfNode.NodeType, out var strat)) - { - arms = strat.GetOutputArms(cfNode); + arms = bfStrat.GetOutputArms(); } if (arms == null) return; @@ -550,7 +542,7 @@ private void BuildEdgesFromTopology( // Use registry to determine edge types for control flow statements if (!string.IsNullOrEmpty(lastStmt.FunctionName) && _builtinFunctionStrategies.TryGetValue(lastStmt.FunctionName, out var builtinStrat) - && builtinStrat.IsControlFlow) + && builtinStrat.IsFlowControl) { // Edges are derived directly from the statement's resolved Arms, so N-way // Switch (Default/0/1/...) and variadic shapes survive without positional loss. @@ -821,29 +813,19 @@ private static void SetParentLoopReferences(ControlFlowGraph cfg) // from the node's function name and input pin values instead of // silently dropping the node (which would leave orphaned PubVar // references and cause CS0103 in downstream code generation). - // PluginCallFunction used to return null before its ToStatement was - // implemented; this fallback ensures other builtins are safe too. Log.Debug("[BP2CFGConverter] BuiltinFunction '{FuncName}' ToStatement returned null, " + "using generic expression fallback", bfNode.FunctionName); return BuildGenericBuiltinStatement(bfNode, bfNode.FunctionName); } return null; } + // Const / Variable / other node types carry no executable statement — the former + // _strategies fallback is gone (it was always empty: every node type either has an + // explicit case above or is a pure data node that produces no BlockStatement). default: - break; - } - - if (!_strategies.TryGetValue(node.NodeType, out var strategy)) - { - Log.Warning("[BP2CFGConverter] Unhandled node type: {NodeType}", node.NodeType); - return null; + Log.Debug("[BP2CFGConverter] Node type {NodeType} produces no statement", node.NodeType); + return null; } - - var stmt = strategy.ToStatement(node, _exportHelper); - if (stmt == null) return null; - - PostProcessCallReturn(node, stmt); - return stmt; } private void PostProcessCallReturn(BlueprintNode node, BlockStatement stmt) @@ -907,19 +889,12 @@ private CFGStatement ConvertBlockStatementToCfgStatement(BlockStatement blockStm case ExpressionStatement expr: cfgStmt.OriginalExpression = expr.SourceCode; - // Kind & FunctionName from strategy (built-in function metadata) + // Kind & FunctionName from builtin function metadata (data-driven, no adapter layer) if (node is BuiltinFunctionNode bfn - && _builtinFunctionStrategies.TryGetValue(bfn.FunctionName, out var bfStrat) - && bfStrat is BuiltinFunctionExportStrategyAdapter bfAdapter) - { - cfgStmt.Kind = bfAdapter.StatementKind; - cfgStmt.FunctionName = bfAdapter.FunctionName; - } - else if (_strategies.TryGetValue(node.NodeType, out var strat) - && strat is BuiltinFunctionExportStrategyAdapter adapter) + && _builtinFunctionStrategies.TryGetValue(bfn.FunctionName, out var bfDef)) { - cfgStmt.Kind = adapter.StatementKind; - cfgStmt.FunctionName = adapter.FunctionName; + cfgStmt.Kind = bfDef.StatementKind; + cfgStmt.FunctionName = bfDef.FunctionName; } else { @@ -1170,6 +1145,6 @@ private static CFGEdgeType GetEdgeType(CFGStatementKind kind, BranchArm arm) private bool IsControlFlowNode(BlueprintNode node) => node is BuiltinFunctionNode bfn - && _builtinFunctionStrategies.TryGetValue(bfn.FunctionName, out var strat) - && strat.IsControlFlow; + && _builtinFunctionStrategies.TryGetValue(bfn.FunctionName, out var def) + && def.IsFlowControl; } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BlueprintToBlockScriptConverter.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BlueprintToBlockScriptConverter.cs index 12ee9953..c6bd0ce5 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BlueprintToBlockScriptConverter.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BlueprintToBlockScriptConverter.cs @@ -13,8 +13,7 @@ namespace KitX.Workflow.Conversion; public class BlueprintToBlockScriptConverter : IBlueprintToBlockScriptConverter { private readonly NodeExportHelper _exportHelper = new(); - private readonly Dictionary _strategyMap; - private readonly Dictionary _builtinMap; + private readonly Dictionary _builtinMap; // CFG pipeline components private readonly BP2CFGConverter _cfgBuilder; @@ -22,24 +21,16 @@ public class BlueprintToBlockScriptConverter : IBlueprintToBlockScriptConverter private readonly CFG2BSConverter _cfg2bs = new(); private readonly BlockScriptSerializer _serializer = new(); - public BlueprintToBlockScriptConverter(IEnumerable strategies) + public BlueprintToBlockScriptConverter(IEnumerable definitions) { - _strategyMap = new Dictionary(); - _builtinMap = new Dictionary(); - - foreach (var s in strategies) - { - if (s is BuiltinFunctionExportStrategyAdapter adapter) - { - _builtinMap[adapter.FunctionName] = s; - } - else - { - _strategyMap[s.NodeType] = s; - } - } - - _cfgBuilder = new BP2CFGConverter(_strategyMap, _builtinMap, _exportHelper); + // Every node export need is satisfied directly by IBuiltinFunctionDefinition + // (ToStatement/GetOutputArms/StatementKind/AutoSynthesizePubVar/IsFlowControl are all + // on the interface). The former INodeExportStrategy + BuiltinFunctionExportStrategyAdapter + // layer was a strict-subset adapter that only forwarded to these members, with the + // dispatcher further special-casing the adapter type — pure indirection, removed. + _builtinMap = definitions.ToDictionary(d => d.FunctionName); + + _cfgBuilder = new BP2CFGConverter(_builtinMap, _exportHelper); } internal ControlFlowGraph? LastCFG { get; private set; } @@ -64,7 +55,7 @@ public BlockScript ConvertToBlockScript(KitX.Core.Contract.Workflow.Blueprint bl _cfgBuilder.SetContext(blueprint, ctx); // Phase 1: BP → CFG via pipeline - var cfg = CFGPipeline.BP2CFG(blueprint, _strategyMap, _builtinMap, _exportHelper, prebuiltBuilder: _cfgBuilder); + var cfg = CFGPipeline.BP2CFG(blueprint, _builtinMap, _exportHelper, prebuiltBuilder: _cfgBuilder); LastCFG = cfg; LastDiagnostics = ctx.Diagnostics; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BuiltinFunctionExportStrategyAdapter.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BuiltinFunctionExportStrategyAdapter.cs deleted file mode 100644 index 78f3aaa4..00000000 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BuiltinFunctionExportStrategyAdapter.cs +++ /dev/null @@ -1,43 +0,0 @@ -using KitX.Core.Contract.Workflow; -using KitX.Workflow.BlockScripting; -using KitX.Workflow.CFG; - -using KitX.Workflow.Blueprint; -namespace KitX.Workflow.Conversion; - -/// -/// 将 适配为 , -/// 消除了每个内置函数需要单独导出策略类的散弹式修改。 -/// -public class BuiltinFunctionExportStrategyAdapter : INodeExportStrategy -{ - private readonly IBuiltinFunctionDefinition _definition; - - public BuiltinFunctionExportStrategyAdapter(IBuiltinFunctionDefinition definition) - { - _definition = definition; - } - - /// 关联的函数名(用于调试和日志) - public string FunctionName => _definition.FunctionName; - - /// - public BlueprintNodeType NodeType => BlueprintNodeType.BuiltinFunction; - - /// 代理底层定义的 StatementKind,用于 BP→CFG 时直接填充 CFGStatement 字段。 - public CFGStatementKind StatementKind => _definition.StatementKind; - - /// 代理底层定义:BP→CFG 时是否为该节点的空 PubVar 数据连接自动合成 PubVar。 - public bool AutoSynthesizePubVar => _definition.AutoSynthesizePubVar; - - /// - public bool IsControlFlow => _definition.IsFlowControl; - - /// - public BlockStatement? ToStatement(BlueprintNode node, INodeExportHelper helper) - => _definition.ToStatement(node, helper); - - /// - public IEnumerable GetOutputArms(BlueprintNode node) - => _definition.GetOutputArms(); -} diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFGPipeline.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFGPipeline.cs index f71c2d8e..81a35187 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFGPipeline.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFGPipeline.cs @@ -54,12 +54,11 @@ internal static ControlFlowGraph BS2CFG( /// internal static ControlFlowGraph BP2CFG( KitX.Core.Contract.Workflow.Blueprint blueprint, - Dictionary strategyMap, - Dictionary builtinMap, + Dictionary builtinMap, NodeExportHelper exportHelper, BP2CFGConverter? prebuiltBuilder = null) { - var builder = prebuiltBuilder ?? new BP2CFGConverter(strategyMap, builtinMap, exportHelper); + var builder = prebuiltBuilder ?? new BP2CFGConverter(builtinMap, exportHelper); if (prebuiltBuilder == null) { builder.SetContext(blueprint, new ConversionContext diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Hosting/ServiceCollectionExtensions.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Hosting/ServiceCollectionExtensions.cs index 0f364ad6..dce2b495 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Hosting/ServiceCollectionExtensions.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Hosting/ServiceCollectionExtensions.cs @@ -115,11 +115,11 @@ public static IServiceCollection AddKitXWorkflow(this IServiceCollection service }); services.AddSingleton(); - // Blueprint Export Strategies — all auto-registered from IBuiltinFunctionDefinition implementations - foreach (var def in functionRegistry.AllDefinitions) - { - services.AddSingleton(new BuiltinFunctionExportStrategyAdapter(def)); - } + // Built-in function definitions feed BlueprintToBlockScriptConverter directly (the + // former INodeExportStrategy + BuiltinFunctionExportStrategyAdapter indirection was a + // strict-subset forwarder removed in this refactor). The registry remains the single + // source of truth for builtins; expose it as IEnumerable. + services.AddSingleton>(functionRegistry.AllDefinitions); // Blueprint Services services.AddSingleton(); From 7f25804b3627f8b98c10ac6e03e6e829dffc233a Mon Sep 17 00:00:00 2001 From: StarInk Date: Sat, 20 Jun 2026 06:39:01 +0200 Subject: [PATCH 100/110] =?UTF-8?q?=F0=9F=A7=A9=20Refactor(Workflow):=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=8B=93=E6=89=91=E8=B7=AF=E5=BE=84=E5=B5=8C?= =?UTF-8?q?=E5=A5=97=E6=8E=A7=E5=88=B6=E6=B5=81=E6=BC=8F=E5=B1=95=E5=BC=80?= =?UTF-8?q?-KISS=E4=BC=98=E5=8C=96=E9=98=B6=E6=AE=B5L9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L9 有效子集(经深挖评估,只做代价/收益比为正的改动): 项2 必做:修复 pendingControlFlowNodes 传递 bug。 - ProcessControlFlowSubGraph 递归调用 WalkNode 时传 pendingControlFlowNodes: new() (空列表),导致嵌套控制流节点(如 Branch 的 True arm 里套的 Loop)被加进 局部丢弃列表,ProcessSubGraphs 的外层 while 循环永远看不到它们——嵌套控制流 的块/arms 永不生成。 - 修复:给 ProcessControlFlowSubGraph 签名加 pendingControlFlowNodes 参数, ProcessSubGraphs 透传外层 pending 引用,WalkNode 递归调用改用该引用而非 new()。 visited 集合保证不重复展开/不死循环。 - 配套新增 Test W:手工构造无 BlockScopes 的 Blueprint(强制走拓扑路径), Branch.True → Loop 嵌套,断言生成的 BlockScript 含 Loop 指令。修复前 Loop 被 丢弃,修复后 Loop(c,"","") 正确生成在 Block_0。 跳过项(经评估净收益为零或负,同 L5-L8 的诚实判断): - 项1 双 Arms 路径合并:ProcessControlFlowSubGraph(拓扑路径,建块+填 Arms)与 ResolveControlFlowTargetsForNode(Scopes 路径,仅填 Arms)职责互补、触发互斥, 合并增加单方法分支复杂度。 - 项3 _currentCtx 参数透传:活字段(Build 中途被回填),非死代码;参数透传要给 5 个方法加 ctx 参,WalkNode 已 13 参,风格偏好非真简化。 - 项4 WalkNode 13 参打包:6 个核心参(node/currentBlock/visited/pending/ref blockCounter/loopbackTargetId)无法打包,调用点仅 3 处,引入只用一次的 struct 是增加间接层。 - 项5 GenerateBlockStatement switch→registry:L7 已删等价的 _strategies 死字典, 5 case 逻辑各异无公共形态,registry 是其复活。 验证:dotnet build 0 错误;测试 A-W 全 PASS(33 PASS / 0 FAIL), 含原 22 测试 + 新 Test W(嵌套控制流拓扑路径)。 # KitX Workflow KISS 深度重构 L9 阶段:修复拓扑路径嵌套控制流漏展开 # KISS 9 层重构全部完成(L0-L4 实做,L5/L7 实做有效子集,L6/L8/L9 多项经评估跳过) # Date: 2026-06-19 # Author: StarInk --- .../KitX.Core.BluePrint.Test/Program.cs | 70 ++++++++++++++++++- .../Conversion/BP2CFGConverter.cs | 9 ++- 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs b/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs index a986e189..72af3582 100644 --- a/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs +++ b/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs @@ -260,6 +260,14 @@ public static void Main(string[] args) Console.WriteLine("└──────────────────────────────────────────────┘\n"); RunStringConcatRoundTripTest(parser, converter, reverseConverter, new List()); } + + if (ShouldRunTest("W")) + { + Console.WriteLine("\n┌──────────────────────────────────────────────────────┐"); + Console.WriteLine("│ Test W: Nested control flow (topology path) │"); + Console.WriteLine("└──────────────────────────────────────────────────────┘\n"); + RunNestedControlFlowTopologyTest(reverseConverter, nodeRegistry, "Test W"); + } } // ────────────────────────────────────────────── @@ -964,7 +972,67 @@ private static void RunManualBlueprintTest( } // ────────────────────────────────────────────── - // Test F: Pure sequential flow (no Branch/Loop) + // Test W: Nested control flow on the topology path. + // A hand-built Blueprint (no BlockScopes → forces BuildBlocksFromTopology / WalkNode / + // ProcessSubGraphs) with a Loop nested inside a Branch's True arm. Before the L9 pending + // fix, WalkNode's recursive call passed `pendingControlFlowNodes: new()`, so the nested + // Loop node was queued into a throwaway list and never expanded — its LoopBody/LoopEnd + // arms were dropped. This test asserts the Loop's arms survive the BP→BS conversion. + // ────────────────────────────────────────────── + private static void RunNestedControlFlowTopologyTest( + IBlueprintToBlockScriptConverter reverseConverter, INodeRegistry nodeRegistry, string label) + { + try + { + var bp = new Contract.Workflow.Blueprint { Name = "NestedControlFlowTopology" }; + + // Entry → Branch(cond, TrueArm, FalseArm); TrueArm → Loop(cond, LoopBody, LoopEnd) + var entry = new EntryNode(); + var branch = nodeRegistry.CreateBuiltinFunctionNode("Branch"); + var loop = nodeRegistry.CreateBuiltinFunctionNode("Loop"); + var constTrue = new ConstNode { ConstName = "c", ConstType = "bool", ConstValue = "true" }; + + bp.AddNode(entry); + bp.AddNode(branch); + bp.AddNode(loop); + bp.AddNode(constTrue); + + BlueprintConnection Conn(string sNode, string sPin, string tNode, string tPin) => new() + { + SourceNodeId = sNode, + SourcePinId = sPin, + TargetNodeId = tNode, + TargetPinId = tPin + }; + string OutPin(BlueprintNode n, string name) => n.OutputPins.First(p => p.Name == name).Id; + string InPin(BlueprintNode n, string name) => n.InputPins.First(p => p.Name == name).Id; + + // Exec: Entry → Branch + bp.AddConnection(Conn(entry.Id, OutPin(entry, "Exec"), branch.Id, InPin(branch, "Exec"))); + // Branch.True → Loop (nested control flow inside Branch's True arm) + bp.AddConnection(Conn(branch.Id, OutPin(branch, "True"), loop.Id, InPin(loop, "Exec"))); + // Data: const → Branch.Condition and const → Loop.Condition + bp.AddConnection(Conn(constTrue.Id, OutPin(constTrue, "Value"), branch.Id, InPin(branch, "Condition"))); + bp.AddConnection(Conn(constTrue.Id, OutPin(constTrue, "Value"), loop.Id, InPin(loop, "Condition"))); + + var script = reverseConverter.Convert(bp); + + // The generated BlockScript must contain the Loop directive. Before the L9 fix, + // the nested Loop was dropped by the throwaway pending list, so the script held + // only the Branch with an empty/unresolved True arm. + bool hasLoop = script.Contains("Loop("); + Console.WriteLine($" Generated script:\n{script}"); + Console.WriteLine($" Loop directive present: {hasLoop}"); + + Console.WriteLine($"[{label}] {(hasLoop ? "PASS - nested Loop arms survived" : "FAIL - nested Loop dropped")}"); + } + catch (Exception ex) + { + Console.WriteLine($"[{label}] FAILED: {ex.Message}"); + Console.WriteLine($" {ex.StackTrace}"); + } + } + // ────────────────────────────────────────────── private static string GetSequentialScript() => @"#ConstBlock string greeting = ""Hello""; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BP2CFGConverter.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BP2CFGConverter.cs index c2634032..262fc3fe 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BP2CFGConverter.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BP2CFGConverter.cs @@ -446,7 +446,7 @@ private void ProcessSubGraphs( processedNodes.Add(node.Id); ProcessControlFlowSubGraph(node, cfg, nodeById, reachableNodeIds, execConns, - blueprint, loopNodes, loopOwnerBlockNames, ref blockCounter); + blueprint, pendingControlFlowNodes, loopNodes, loopOwnerBlockNames, ref blockCounter); } } } @@ -455,6 +455,7 @@ private void ProcessControlFlowSubGraph( BlueprintNode cfNode, ControlFlowGraph cfg, Dictionary nodeById, HashSet reachableNodeIds, List execConns, KitX.Core.Contract.Workflow.Blueprint blueprint, + List pendingControlFlowNodes, Dictionary loopNodes, Dictionary loopOwnerBlockNames, ref int blockCounter) { @@ -495,8 +496,12 @@ private void ProcessControlFlowSubGraph( if (isLoopback) loopOwnerBlockNames[cfNode.Id] = blockName; + // Propagate the shared pending list so nested control-flow nodes discovered during + // sub-graph walking are queued for ProcessSubGraphs' outer loop. Previously this + // passed `new()`, dropping nested control-flow nodes (e.g. a Loop inside a Branch's + // True arm) on the floor — their blocks/arms were never built on the topology path. WalkNode(targetNode, block, cfg, nodeById, reachableNodeIds, execConns, - blueprint, new HashSet(), pendingControlFlowNodes: new(), + blueprint, new HashSet(), pendingControlFlowNodes, loopNodes, loopOwnerBlockNames, ref blockCounter, loopbackTargetId: isLoopback ? cfNode.Id : null); From 65c305f2d6764f9c87f7e17922667dfe8f0ed89e Mon Sep 17 00:00:00 2001 From: StarInk Date: Sat, 20 Jun 2026 09:15:23 +0200 Subject: [PATCH 101/110] =?UTF-8?q?=F0=9F=A7=A9=20Refactor(Workflow):=20?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E7=BB=84=E7=BB=87=E9=87=8D=E6=9E=84-Compilat?= =?UTF-8?q?ion/Abstractions/=E5=91=BD=E5=90=8D=E7=BB=9F=E4=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 纯组织结构重构(文件位置/命名空间/命名调整)+ 删死委托门面与过度抽象接口。 基于四轮深挖调研,每项影响面已核实。4 项目 build 0 错误 + 测试 A-W 全 PASS。 1. 新建 Compilation/ 内聚编译链(以 CFG 为分界线): - 9 个编译链文件从 BlockScripting/、Conversion/、根目录聚拢到 Compilation/, namespace KitX.Workflow.Compilation:CSCompiler/ScriptCompilationBackend/ CompiledScriptEntry(含ALC)/ScriptPersistenceManager/CompiledScriptMeta/ ICompiledBlockScript/CSEmitContext(从BlockScripting)+ CFG2CSConverter (从Conversion,原CFG2CSGenerator)+ ConstantValueRewriter(从根目录)。 - 修复了 CFG2CSConverter 生成脚本模板的 namespace/usings(BlockScripting.Generated →Compilation.Generated,加 using Compilation)。 - GlobalUsings 加 Compilation/BlockScripting/Conversion/CFG 四个核心命名空间。 2. Contract→Abstractions 重命名 + 子命名空间对齐: - Contract/ → Abstractions/,namespace KitX.Workflow.Contract → .Abstractions。 - Models/Statements/ → .Abstractions.Models.Statements,Results/ → .Results。 - IBlockScriptExecutor 保留在 Abstractions(移到 Core.Contract.Workflow 会引入 对内部 BlockScript 模型的跨依赖),修正其错误注释(实为对外契约,Dashboard 直接注入)。 - Dashboard/测试同步改 using。 3. 删 WorkflowScriptService 死委托门面 + 删 IBlockScriptPipelineService: - WorkflowScriptService 的 4 接口实现全是死委托(DI 从不解析它走这些接口), 删除接口实现,瘦身为静态工厂容器(RuntimeState/PluginServiceInstance/ ManagementServiceInstance 仍被 DI lambda 引用)。 - IBlockScriptPipelineService 零接口消费者(WorkflowManagementService 用具体类), 删除接口+DI注册,BlockScriptServiceImpl 只实现 IBlockScriptService。 - 其余 3 个过度抽象接口(IBlockScopeManager/ILayoutService/IBlockScriptToBlueprintConverter) 有活 DI 消费者,保留(删除涉及 DI 逻辑变更,超出纯组织重构范畴)。 4. 命名统一化: - CFGConditionDuplicator namespace 从 CFG 修正为 Conversion(与文件夹对齐)。 - BlockScriptModels.cs → BlockType.cs(文件仅含 enum BlockType,复数名误导)。 - CFG2CSGenerator → CFG2CSConverter(与同命名空间 6 个 Converter 统一后缀)。 - IBlockScriptPipeline.cs(3 个无关接口)拆为 IBlockScriptParser.cs + IBlockScopeManager.cs(含 IBlockScope)。 跳过(经评估代价大于收益或属逻辑变更): - IBlockScriptService 与 IBlockScriptPipelineService 合并(接口隔离原则)。 - 删 IBlockScopeManager/ILayoutService/IBlockScriptToBlueprintConverter(有活DI消费者)。 - 步骤5文件拆分/步骤6 Services/ 归集(低价值机械工作,留待后续)。 - Contract 子模块结构(不动,避免破坏 Dashboard)。 # KitX Workflow 后端代码组织结构重构 # Date: 2026-06-19 # Author: StarInk --- .../KcsCompileTest.cs | 7 +- .../KitX.Core.BluePrint.Test/Program.cs | 7 +- KitX Clients/KitX Dashboard | 2 +- .../IBlockScopeManager.cs} | 35 +-- .../IBlockScriptExecutor.cs | 16 +- .../Abstractions/IBlockScriptParser.cs | 31 +++ .../IBlueprintConverters.cs | 6 +- .../ILayoutService.cs | 2 +- .../INodeExportHelper.cs | 2 +- .../IPluginManager.cs | 2 +- .../Models/BlockDefinition.cs | 2 +- .../Models/BlockScript.cs | 2 +- .../Models/BlockType.cs} | 2 +- .../Models/ConversionDiagnostics.cs | 2 +- .../Models/FlowControlType.cs | 2 +- .../Models/PluginCallInfo.cs | 2 +- .../Models/Results/BlockExecutionResult.cs | 2 +- .../Models/Results/BlockScriptParseResult.cs | 2 +- .../Models/Statements/BlockStatement.cs | 2 +- .../Models/Statements/ExpressionStatement.cs | 2 +- .../Models/Statements/FlowControlStatement.cs | 2 +- .../VariableDeclarationStatement.cs | 2 +- .../Models/VariableDeclaration.cs | 2 +- .../KitX.Workflow/BlockScriptServiceImpl.cs | 12 +- .../BlockScriptExecutionGlobals.cs | 2 +- .../BlockScripting/BlockScriptExecutor.cs | 4 +- .../IBuiltinFunctionDefinition.cs | 2 +- .../BuiltinFunctions/GetFunction.cs | 2 +- .../BuiltinFunctions/PluginCallFunction.cs | 2 +- .../PluginCallWithTargetFunction.cs | 2 +- .../CFG2CSConverter.cs} | 15 +- .../CSCompiler.cs | 16 +- .../CSEmitContext.cs | 14 +- .../CompiledScriptEntry.cs | 2 +- .../CompiledScriptMeta.cs | 4 +- .../ConstantValueRewriter.cs | 2 +- .../ICompiledBlockScript.cs | 2 +- .../ScriptCompilationBackend.cs | 6 +- .../ScriptPersistenceManager.cs | 2 +- .../Contract/IBlockScriptPipelineService.cs | 45 ---- .../Conversion/CFGConditionDuplicator.cs | 2 +- .../KitX.Workflow/GlobalUsings.cs | 14 +- .../Hosting/ServiceCollectionExtensions.cs | 5 +- .../KitX.Workflow/WorkflowScriptService.cs | 200 ++++-------------- .../KitX.Workflow/WorkflowStorageService.cs | 2 +- 45 files changed, 173 insertions(+), 320 deletions(-) rename KitX Clients/KitX Workflow/KitX.Workflow/{Contract/IBlockScriptPipeline.cs => Abstractions/IBlockScopeManager.cs} (61%) rename KitX Clients/KitX Workflow/KitX.Workflow/{Contract => Abstractions}/IBlockScriptExecutor.cs (61%) create mode 100644 KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/IBlockScriptParser.cs rename KitX Clients/KitX Workflow/KitX.Workflow/{Contract => Abstractions}/IBlueprintConverters.cs (88%) rename KitX Clients/KitX Workflow/KitX.Workflow/{Contract => Abstractions}/ILayoutService.cs (87%) rename KitX Clients/KitX Workflow/KitX.Workflow/{Contract => Abstractions}/INodeExportHelper.cs (98%) rename KitX Clients/KitX Workflow/KitX.Workflow/{Contract => Abstractions}/IPluginManager.cs (97%) rename KitX Clients/KitX Workflow/KitX.Workflow/{Contract => Abstractions}/Models/BlockDefinition.cs (96%) rename KitX Clients/KitX Workflow/KitX.Workflow/{Contract => Abstractions}/Models/BlockScript.cs (98%) rename KitX Clients/KitX Workflow/KitX.Workflow/{Contract/Models/BlockScriptModels.cs => Abstractions/Models/BlockType.cs} (93%) rename KitX Clients/KitX Workflow/KitX.Workflow/{Contract => Abstractions}/Models/ConversionDiagnostics.cs (99%) rename KitX Clients/KitX Workflow/KitX.Workflow/{Contract => Abstractions}/Models/FlowControlType.cs (94%) rename KitX Clients/KitX Workflow/KitX.Workflow/{Contract => Abstractions}/Models/PluginCallInfo.cs (97%) rename KitX Clients/KitX Workflow/KitX.Workflow/{Contract => Abstractions}/Models/Results/BlockExecutionResult.cs (95%) rename KitX Clients/KitX Workflow/KitX.Workflow/{Contract => Abstractions}/Models/Results/BlockScriptParseResult.cs (94%) rename KitX Clients/KitX Workflow/KitX.Workflow/{Contract => Abstractions}/Models/Statements/BlockStatement.cs (92%) rename KitX Clients/KitX Workflow/KitX.Workflow/{Contract => Abstractions}/Models/Statements/ExpressionStatement.cs (95%) rename KitX Clients/KitX Workflow/KitX.Workflow/{Contract => Abstractions}/Models/Statements/FlowControlStatement.cs (98%) rename KitX Clients/KitX Workflow/KitX.Workflow/{Contract => Abstractions}/Models/Statements/VariableDeclarationStatement.cs (82%) rename KitX Clients/KitX Workflow/KitX.Workflow/{Contract => Abstractions}/Models/VariableDeclaration.cs (93%) rename KitX Clients/KitX Workflow/KitX.Workflow/{Conversion/CFG2CSGenerator.cs => Compilation/CFG2CSConverter.cs} (98%) rename KitX Clients/KitX Workflow/KitX.Workflow/{BlockScripting => Compilation}/CSCompiler.cs (95%) rename KitX Clients/KitX Workflow/KitX.Workflow/{BlockScripting => Compilation}/CSEmitContext.cs (91%) rename KitX Clients/KitX Workflow/KitX.Workflow/{BlockScripting => Compilation}/CompiledScriptEntry.cs (97%) rename KitX Clients/KitX Workflow/KitX.Workflow/{BlockScripting => Compilation}/CompiledScriptMeta.cs (88%) rename KitX Clients/KitX Workflow/KitX.Workflow/{ => Compilation}/ConstantValueRewriter.cs (99%) rename KitX Clients/KitX Workflow/KitX.Workflow/{BlockScripting => Compilation}/ICompiledBlockScript.cs (93%) rename KitX Clients/KitX Workflow/KitX.Workflow/{BlockScripting => Compilation}/ScriptCompilationBackend.cs (96%) rename KitX Clients/KitX Workflow/KitX.Workflow/{BlockScripting => Compilation}/ScriptPersistenceManager.cs (99%) delete mode 100644 KitX Clients/KitX Workflow/KitX.Workflow/Contract/IBlockScriptPipelineService.cs diff --git a/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/KcsCompileTest.cs b/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/KcsCompileTest.cs index b6b36d90..4509611b 100644 --- a/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/KcsCompileTest.cs +++ b/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/KcsCompileTest.cs @@ -6,8 +6,11 @@ using KitX.Core.DI; using KitX.Core.Contract.Workflow; using KitX.Workflow.BlockScripting; -using KitX.Workflow.Contract; -using KitX.Workflow.Contract.Models; +using KitX.Workflow.Compilation; +using KitX.Workflow.Abstractions; +using KitX.Workflow.Abstractions.Models; +using KitX.Workflow.Abstractions.Models.Statements; +using KitX.Workflow.Abstractions.Models.Results; using KitX.Workflow.Blueprint; using KitX.Workflow.CFG; using KitX.Workflow.Conversion; diff --git a/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs b/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs index 72af3582..a78aaae6 100644 --- a/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs +++ b/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs @@ -8,8 +8,11 @@ using KitX.Workflow.Blueprint; using KitX.Workflow.CFG; using KitX.Workflow.BlockScripting; -using KitX.Workflow.Contract; -using KitX.Workflow.Contract.Models; +using KitX.Workflow.Compilation; +using KitX.Workflow.Abstractions; +using KitX.Workflow.Abstractions.Models; +using KitX.Workflow.Abstractions.Models.Statements; +using KitX.Workflow.Abstractions.Models.Results; using KitX.Workflow.Conversion; namespace KitX.Core.BluePrint.Test; diff --git a/KitX Clients/KitX Dashboard b/KitX Clients/KitX Dashboard index bc23747b..5d5fff9b 160000 --- a/KitX Clients/KitX Dashboard +++ b/KitX Clients/KitX Dashboard @@ -1 +1 @@ -Subproject commit bc23747b4c55310129c9cc9390c42da869923add +Subproject commit 5d5fff9bcaf7635423cde652fca5586cb7af498b diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/IBlockScriptPipeline.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/IBlockScopeManager.cs similarity index 61% rename from KitX Clients/KitX Workflow/KitX.Workflow/Contract/IBlockScriptPipeline.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/IBlockScopeManager.cs index 7bac2364..49586776 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/IBlockScriptPipeline.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/IBlockScopeManager.cs @@ -1,38 +1,9 @@ -using System; using System.Collections.Generic; -using System.Threading.Tasks; -using KitX.Core.Contract.Workflow; -namespace KitX.Workflow.Contract; +namespace KitX.Workflow.Abstractions; /// -/// Block script parser interface - parses C# scripts with block attributes. -/// -/// Internal to the workflow pipeline (only BlockScriptExecutor / DI use it); -/// moved out of the public Contract surface. -/// -public interface IBlockScriptParser -{ - /// - /// Parses a block-based script from source code - /// - /// The C# source code with block attributes - /// Parsed block script result - BlockScriptParseResult Parse(string sourceCode); - - /// - /// Parses a block-based script from source code asynchronously - /// - Task ParseAsync(string sourceCode); - - /// - /// Validates block script syntax and structure - /// - BlockScriptValidationResult Validate(string sourceCode); -} - -/// -/// Block scope manager interface - manages variable scoping. +/// Block scope manager interface — manages variable scoping. /// Internal to the workflow pipeline. /// public interface IBlockScopeManager @@ -98,4 +69,4 @@ public interface IBlockScope /// Gets all variables in this scope /// Dictionary GetAllVariables(); -} +} \ No newline at end of file diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/IBlockScriptExecutor.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/IBlockScriptExecutor.cs similarity index 61% rename from KitX Clients/KitX Workflow/KitX.Workflow/Contract/IBlockScriptExecutor.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/IBlockScriptExecutor.cs index e1cb8925..bae0289e 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/IBlockScriptExecutor.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/IBlockScriptExecutor.cs @@ -2,17 +2,19 @@ using System.Threading; using System.Threading.Tasks; using KitX.Core.Contract.Workflow; -using KitX.Workflow.Contract.Models; +using KitX.Workflow.Abstractions.Models; -namespace KitX.Workflow.Contract; +namespace KitX.Workflow.Abstractions; /// -/// Block script executor interface - executes parsed block scripts. +/// Block script executor interface — executes parsed block scripts. /// -/// Moved to the workflow library's internal Contract surface: its methods operate on -/// the parsed BlockScript model (also internal), and the Dashboard no longer -/// invokes executor methods directly (it routes execution through IBlockScriptService / -/// IBlueprintService, which take source strings). +/// The Dashboard consumes this interface directly (BlueprintEditorViewModel injects it to run +/// blueprints), so despite living in the workflow library's internal Abstractions surface it is +/// effectively a cross-module service contract. Its methods operate on the parsed +/// BlockScript model (also internal to this library), which is why it stays here rather +/// than in the public KitX.Core.Contract.Workflow — moving it would require also moving +/// BlockScript out of the internal model layer. /// public interface IBlockScriptExecutor { diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/IBlockScriptParser.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/IBlockScriptParser.cs new file mode 100644 index 00000000..787df063 --- /dev/null +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/IBlockScriptParser.cs @@ -0,0 +1,31 @@ +using System.Threading.Tasks; +using KitX.Core.Contract.Workflow; +using KitX.Workflow.Abstractions.Models.Results; + +namespace KitX.Workflow.Abstractions; + +/// +/// Block script parser interface — parses C# scripts with block attributes. +/// +/// Internal to the workflow pipeline (only BlockScriptExecutor / DI use it); moved out of the +/// public Contract surface. Consumed cross-assembly by the KitX.Core.BluePrint.Test project. +/// +public interface IBlockScriptParser +{ + /// + /// Parses a block-based script from source code + /// + /// The C# source code with block attributes + /// Parsed block script result + BlockScriptParseResult Parse(string sourceCode); + + /// + /// Parses a block-based script from source code asynchronously + /// + Task ParseAsync(string sourceCode); + + /// + /// Validates block script syntax and structure + /// + BlockScriptValidationResult Validate(string sourceCode); +} \ No newline at end of file diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/IBlueprintConverters.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/IBlueprintConverters.cs similarity index 88% rename from KitX Clients/KitX Workflow/KitX.Workflow/Contract/IBlueprintConverters.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/IBlueprintConverters.cs index 9cea76cd..f973a1ed 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/IBlueprintConverters.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/IBlueprintConverters.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; -namespace KitX.Workflow.Contract; +namespace KitX.Workflow.Abstractions; /// /// Interface for converting BlockScript to Blueprint. @@ -21,7 +21,7 @@ public interface IBlockScriptToBlueprintConverter /// /// Parsed BlockScript /// Converted Blueprint - KitX.Core.Contract.Workflow.Blueprint Convert(KitX.Workflow.Contract.Models.BlockScript script); + KitX.Core.Contract.Workflow.Blueprint Convert(KitX.Workflow.Abstractions.Models.BlockScript script); } /// @@ -42,5 +42,5 @@ public interface IBlueprintToBlockScriptConverter /// /// Blueprint to convert /// Parsed BlockScript - KitX.Workflow.Contract.Models.BlockScript ConvertToBlockScript(KitX.Core.Contract.Workflow.Blueprint blueprint); + KitX.Workflow.Abstractions.Models.BlockScript ConvertToBlockScript(KitX.Core.Contract.Workflow.Blueprint blueprint); } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/ILayoutService.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/ILayoutService.cs similarity index 87% rename from KitX Clients/KitX Workflow/KitX.Workflow/Contract/ILayoutService.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/ILayoutService.cs index 18ac67dc..f7de58ba 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/ILayoutService.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/ILayoutService.cs @@ -1,4 +1,4 @@ -namespace KitX.Workflow.Contract; +namespace KitX.Workflow.Abstractions; /// /// Interface for layout service. diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/INodeExportHelper.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/INodeExportHelper.cs similarity index 98% rename from KitX Clients/KitX Workflow/KitX.Workflow/Contract/INodeExportHelper.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/INodeExportHelper.cs index a79dea1b..0e187f20 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/INodeExportHelper.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/INodeExportHelper.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; -namespace KitX.Workflow.Contract; +namespace KitX.Workflow.Abstractions; using ContractWorkflow = KitX.Core.Contract.Workflow; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/IPluginManager.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/IPluginManager.cs similarity index 97% rename from KitX Clients/KitX Workflow/KitX.Workflow/Contract/IPluginManager.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/IPluginManager.cs index 69fd9451..6c72ec1e 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/IPluginManager.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/IPluginManager.cs @@ -1,6 +1,6 @@ using KitX.Core.Contract.Workflow; -namespace KitX.Workflow.Contract; +namespace KitX.Workflow.Abstractions; /// /// 插件管理器接口 - BlockScripting 使用此接口调用插件方法。 diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/BlockDefinition.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/BlockDefinition.cs similarity index 96% rename from KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/BlockDefinition.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/BlockDefinition.cs index f29a04d5..7342ca32 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/BlockDefinition.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/BlockDefinition.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; -namespace KitX.Workflow.Contract.Models; +namespace KitX.Workflow.Abstractions.Models; /// /// Represents a single block definition in the script diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/BlockScript.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/BlockScript.cs similarity index 98% rename from KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/BlockScript.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/BlockScript.cs index 2ae2d2af..413ead04 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/BlockScript.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/BlockScript.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; using KitX.Core.Contract.Workflow; -namespace KitX.Workflow.Contract.Models; +namespace KitX.Workflow.Abstractions.Models; /// /// Parsed block script container diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/BlockScriptModels.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/BlockType.cs similarity index 93% rename from KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/BlockScriptModels.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/BlockType.cs index 551f7a34..82a061b7 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/BlockScriptModels.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/BlockType.cs @@ -1,4 +1,4 @@ -namespace KitX.Workflow.Contract.Models; +namespace KitX.Workflow.Abstractions.Models; /// /// Block type enumeration diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/ConversionDiagnostics.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/ConversionDiagnostics.cs similarity index 99% rename from KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/ConversionDiagnostics.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/ConversionDiagnostics.cs index 27a9ebb6..71fd62c3 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/ConversionDiagnostics.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/ConversionDiagnostics.cs @@ -2,7 +2,7 @@ using System.Linq; using System.Text; -namespace KitX.Workflow.Contract.Models; +namespace KitX.Workflow.Abstractions.Models; /// /// Severity of a conversion/parse diagnostic. Mirrors the compile-path model so the diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/FlowControlType.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/FlowControlType.cs similarity index 94% rename from KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/FlowControlType.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/FlowControlType.cs index e4600d30..264f818a 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/FlowControlType.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/FlowControlType.cs @@ -1,4 +1,4 @@ -namespace KitX.Workflow.Contract.Models; +namespace KitX.Workflow.Abstractions.Models; /// /// Flow control statement types diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/PluginCallInfo.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/PluginCallInfo.cs similarity index 97% rename from KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/PluginCallInfo.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/PluginCallInfo.cs index 29e5427c..ba86e7a3 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/PluginCallInfo.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/PluginCallInfo.cs @@ -1,6 +1,6 @@ using System; -namespace KitX.Workflow.Contract.Models; +namespace KitX.Workflow.Abstractions.Models; /// /// 插件调用信息,用于传递给 IPluginManager.Call 的参数。 diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/Results/BlockExecutionResult.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/Results/BlockExecutionResult.cs similarity index 95% rename from KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/Results/BlockExecutionResult.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/Results/BlockExecutionResult.cs index 99885ddb..fe90c3a5 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/Results/BlockExecutionResult.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/Results/BlockExecutionResult.cs @@ -1,4 +1,4 @@ -namespace KitX.Workflow.Contract.Models; +namespace KitX.Workflow.Abstractions.Models.Results; /// /// Result of executing a block - used by state machine for flow control diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/Results/BlockScriptParseResult.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/Results/BlockScriptParseResult.cs similarity index 94% rename from KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/Results/BlockScriptParseResult.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/Results/BlockScriptParseResult.cs index cc110135..c3bf6d0c 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/Results/BlockScriptParseResult.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/Results/BlockScriptParseResult.cs @@ -1,4 +1,4 @@ -namespace KitX.Workflow.Contract.Models; +namespace KitX.Workflow.Abstractions.Models.Results; /// /// Result of parsing operation diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/Statements/BlockStatement.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/Statements/BlockStatement.cs similarity index 92% rename from KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/Statements/BlockStatement.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/Statements/BlockStatement.cs index 8eed3ccc..0ca8ec76 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/Statements/BlockStatement.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/Statements/BlockStatement.cs @@ -1,4 +1,4 @@ -namespace KitX.Workflow.Contract.Models; +namespace KitX.Workflow.Abstractions.Models.Statements; /// /// Base class for statements within a block diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/Statements/ExpressionStatement.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/Statements/ExpressionStatement.cs similarity index 95% rename from KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/Statements/ExpressionStatement.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/Statements/ExpressionStatement.cs index 5c0e7d22..8802f144 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/Statements/ExpressionStatement.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/Statements/ExpressionStatement.cs @@ -1,6 +1,6 @@ using Microsoft.CodeAnalysis.CSharp.Syntax; -namespace KitX.Workflow.Contract.Models; +namespace KitX.Workflow.Abstractions.Models.Statements; /// /// Expression statement diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/Statements/FlowControlStatement.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/Statements/FlowControlStatement.cs similarity index 98% rename from KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/Statements/FlowControlStatement.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/Statements/FlowControlStatement.cs index cc4e5a8d..a3bc5010 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/Statements/FlowControlStatement.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/Statements/FlowControlStatement.cs @@ -1,6 +1,6 @@ using KitX.Core.Contract.Workflow; -namespace KitX.Workflow.Contract.Models; +namespace KitX.Workflow.Abstractions.Models.Statements; /// /// Flow control statement diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/Statements/VariableDeclarationStatement.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/Statements/VariableDeclarationStatement.cs similarity index 82% rename from KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/Statements/VariableDeclarationStatement.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/Statements/VariableDeclarationStatement.cs index 16ac10b7..7e9eecb7 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/Statements/VariableDeclarationStatement.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/Statements/VariableDeclarationStatement.cs @@ -1,4 +1,4 @@ -namespace KitX.Workflow.Contract.Models; +namespace KitX.Workflow.Abstractions.Models.Statements; /// /// Variable declaration statement diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/VariableDeclaration.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/VariableDeclaration.cs similarity index 93% rename from KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/VariableDeclaration.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/VariableDeclaration.cs index 99368a34..973c782b 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/Models/VariableDeclaration.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/VariableDeclaration.cs @@ -1,4 +1,4 @@ -namespace KitX.Workflow.Contract.Models; +namespace KitX.Workflow.Abstractions.Models; /// /// Variable declaration diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScriptServiceImpl.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScriptServiceImpl.cs index 3b48190c..e1fa54c1 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScriptServiceImpl.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScriptServiceImpl.cs @@ -1,17 +1,19 @@ using KitX.Core.Contract.Workflow; using KitX.Workflow.BlockScripting; -using KitX.Workflow.Contract; +using KitX.Workflow.Abstractions; using Serilog; namespace KitX.Workflow; /// /// BlockScript parsing and execution service. -/// Implements both the public IBlockScriptService (Dashboard-facing, source-string -/// based methods) and the internal IBlockScriptPipelineService (parsed-model based -/// methods consumed only by the workflow pipeline). +/// Implements the public IBlockScriptService (Dashboard-facing, source-string based +/// methods). The parsed-model based methods (ParseBlockScript / ExecuteBlockScriptAsync(BlockScript) +/// / CompileAndPersistAsync) live on this concrete class and are consumed directly by +/// WorkflowManagementService — the former IBlockScriptPipelineService indirection was removed +/// as it had no interface-type consumers. /// -internal class BlockScriptServiceImpl : IBlockScriptService, IBlockScriptPipelineService +internal class BlockScriptServiceImpl : IBlockScriptService { private readonly WorkflowRuntimeState _state; private RealPluginManager? _realPluginManager; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptExecutionGlobals.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptExecutionGlobals.cs index 5c6e47ad..b5f71111 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptExecutionGlobals.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptExecutionGlobals.cs @@ -24,7 +24,7 @@ public partial class BlockScriptExecutionGlobals /// 这是控制流的唯一载体。普通 Set/Get 不再识别 "NextBlock" 这个名字, /// 因此用户脚本无法通过变量赋值劫持控制流、绕过 Loop/Break 语义或 CFG 校验。 /// 仅两类调用方可写此属性:(1) 生成的 RunAsync 主干(G.NextBlock = ...)—— - /// 它是可信基础设施,完全由 CFG2CSGenerator 按已校验的 CFG 产出;(2) 受信任的 flow + /// 它是可信基础设施,完全由 CFG2CSConverter 按已校验的 CFG 产出;(2) 受信任的 flow /// 函数(Branch/Loop/Switch/Flip/ToLoopCond),应优先通过 写入。 /// public string? NextBlock { get; set; } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptExecutor.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptExecutor.cs index 6362e5a9..59341298 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptExecutor.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptExecutor.cs @@ -65,7 +65,7 @@ public void SetWorkflowId(string? workflowId) public void SetDebugger(IBlueprintDebugController? debugger) { _debugger = debugger; - CFG2CSGenerator.IsDebugMode = debugger != null; + CFG2CSConverter.IsDebugMode = debugger != null; } /// @@ -149,7 +149,7 @@ private async Task ExecuteCoreAsync( try { - CFG2CSGenerator.IsDebugMode = _debugger != null; + CFG2CSConverter.IsDebugMode = _debugger != null; var (compiled, compileErrors) = compileFn(); if (compiled == null) diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/IBuiltinFunctionDefinition.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/IBuiltinFunctionDefinition.cs index 1e8a71c5..dc5327a6 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/IBuiltinFunctionDefinition.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/IBuiltinFunctionDefinition.cs @@ -153,7 +153,7 @@ List LowerToCFG( // ─── CS 生成(CFGStatement → C#)──────────── /// - /// 按 生成 C# 语句(CFG→CS 阶段)。由 CFG2CSGenerator + /// 按 生成 C# 语句(CFG→CS 阶段)。由 CFG2CSConverter /// 统一派发调用,使生成器无需针对具体函数名硬编码分支。 /// 默认实现走通用 G.{FunctionName}(args) 形式 + 赋值包裹; /// 需要自定义代码生成的函数覆写此方法。 diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/GetFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/GetFunction.cs index 40904696..76740bc8 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/GetFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/GetFunction.cs @@ -41,7 +41,7 @@ public List EmitStatements(CFGStatement stmt, CSEmitContext ctx // Without this, Get always returns object, and passing it to a function expecting // string/int/bool causes CS1503. Falls back to object if the variable is unknown. var typeName = ctx.PubVarTypes.GetValueOrDefault(varName, "object"); - return ctx.EmitValueAssignment(stmt.PubVarTarget, CFG2CSGenerator.BuildGetInvocation(varName, typeName)); + return ctx.EmitValueAssignment(stmt.PubVarTarget, CFG2CSConverter.BuildGetInvocation(varName, typeName)); } public List LowerToCFG( diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PluginCallFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PluginCallFunction.cs index 8931d239..2bddc07f 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PluginCallFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PluginCallFunction.cs @@ -43,7 +43,7 @@ public class PluginCallFunction : IBuiltinFunctionDefinition public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; public List EmitStatements(CFGStatement stmt, CSEmitContext ctx) - => ctx.EmitValueAssignment(stmt.PubVarTarget, CFG2CSGenerator.BuildPluginCallExpression(stmt, ctx.PubVarTypes)); + => ctx.EmitValueAssignment(stmt.PubVarTarget, CFG2CSConverter.BuildPluginCallExpression(stmt, ctx.PubVarTypes)); public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) { diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PluginCallWithTargetFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PluginCallWithTargetFunction.cs index caedfd1c..f5861b02 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PluginCallWithTargetFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PluginCallWithTargetFunction.cs @@ -40,7 +40,7 @@ public class PluginCallWithTargetFunction : IBuiltinFunctionDefinition public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; public List EmitStatements(CFGStatement stmt, CSEmitContext ctx) - => ctx.EmitValueAssignment(stmt.PubVarTarget, CFG2CSGenerator.BuildPluginCallWithTargetExpression(stmt, ctx.PubVarTypes)); + => ctx.EmitValueAssignment(stmt.PubVarTarget, CFG2CSConverter.BuildPluginCallWithTargetExpression(stmt, ctx.PubVarTypes)); public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) { diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFG2CSGenerator.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Compilation/CFG2CSConverter.cs similarity index 98% rename from KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFG2CSGenerator.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Compilation/CFG2CSConverter.cs index f3d481d9..375f19cb 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFG2CSGenerator.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Compilation/CFG2CSConverter.cs @@ -9,7 +9,7 @@ using KitX.Workflow.BlockScripting; using KitX.Workflow.Blueprint; -namespace KitX.Workflow.Conversion; +namespace KitX.Workflow.Compilation; /// /// Generates Roslyn from a . @@ -18,10 +18,10 @@ namespace KitX.Workflow.Conversion; /// The generated class implements with a Run method /// that executes all blocks via a while(true) + switch(G.NextBlock) dispatcher. /// -internal static class CFG2CSGenerator +internal static class CFG2CSConverter { private static readonly BuiltinFunctionRegistry FunctionRegistry = - BuiltinFunctionRegistry.Discover(typeof(CFG2CSGenerator).Assembly); + BuiltinFunctionRegistry.Discover(typeof(CFG2CSConverter).Assembly); public static bool IsDebugMode { get; set; } @@ -117,7 +117,7 @@ internal static Dictionary InferPubVarTypes( } } - Log.Debug("[CFG2CSGenerator] Type inference: {Count} PubVars typed: {Types}", + Log.Debug("[CFG2CSConverter] Type inference: {Count} PubVars typed: {Types}", pubVarTypes.Count, string.Join(", ", pubVarTypes.Select(kv => $"{kv.Key}={kv.Value}"))); @@ -132,7 +132,7 @@ internal static Dictionary InferPubVarTypes( /// Generates the complete for the compiled script. /// Structure: /// - /// namespace KitX.Workflow.BlockScripting.Generated { + /// namespace KitX.Workflow.Compilation.Generated { /// public class CompiledScript_<hash> : ICompiledBlockScript { /// public static T ConvertTo<T>(object? value) { ... } /// // Helper functions as static methods (typed, NO wrappers) @@ -165,7 +165,7 @@ internal static CompilationUnitSyntax GenerateCompilationUnit( classDecl = classDecl.AddMembers(runMethod); var nsDecl = NamespaceDeclaration( - ParseName("KitX.Workflow.BlockScripting.Generated")) + ParseName("KitX.Workflow.Compilation.Generated")) .AddMembers(classDecl); var usings = new List @@ -174,6 +174,7 @@ internal static CompilationUnitSyntax GenerateCompilationUnit( UsingDirective(ParseName("System.Threading")), UsingDirective(ParseName("System.Threading.Tasks")), UsingDirective(ParseName("KitX.Core.Contract.Workflow")), + UsingDirective(ParseName("KitX.Workflow.Compilation")), UsingDirective(ParseName("KitX.Workflow.BlockScripting")) }; @@ -286,7 +287,7 @@ internal static List ParseHelperFunctionBody(string? code) return block.Statements.ToList(); } - Log.Warning("[CFG2CSGenerator] Failed to parse helper function body, using empty body"); + Log.Warning("[CFG2CSConverter] Failed to parse helper function body, using empty body"); return new List { ReturnStatement( LiteralExpression(SyntaxKind.DefaultLiteralExpression)) }; } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/CSCompiler.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Compilation/CSCompiler.cs similarity index 95% rename from KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/CSCompiler.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Compilation/CSCompiler.cs index 0bbbee4e..ff1c2e30 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/CSCompiler.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Compilation/CSCompiler.cs @@ -4,7 +4,7 @@ using KitX.Workflow.CFG; using Microsoft.CodeAnalysis.CSharp.Syntax; -namespace KitX.Workflow.BlockScripting; +namespace KitX.Workflow.Compilation; /// /// Compiles an entire into a .NET assembly using Roslyn @@ -12,7 +12,7 @@ namespace KitX.Workflow.BlockScripting; /// implementing with a Run method. /// /// This class acts as a coordinator, delegating to specialized components: -/// for Roslyn syntax generation, +/// for Roslyn syntax generation, /// for compilation, and /// for disk caching. /// @@ -85,7 +85,7 @@ public CSCompiler() // Phase 1: Format script + infer PubVar types var (formattedScript, pubVarTypes) = FormatAndInferTypes(script); // Phase 2: Generate CompilationUnitSyntax - return CFG2CSGenerator.GenerateCompilationUnit(script, formattedScript, pubVarTypes, hash); + return CFG2CSConverter.GenerateCompilationUnit(script, formattedScript, pubVarTypes, hash); }, "Successfully compiled and cached script hash", out compileErrors); } @@ -108,14 +108,14 @@ public CSCompiler() out IReadOnlyList compileErrors) { var baseHash = ScriptCompilationBackend.ComputeScriptHash(script); - var hash = CFG2CSGenerator.IsDebugMode ? $"debug_{baseHash}" : baseHash; + var hash = CFG2CSConverter.IsDebugMode ? $"debug_{baseHash}" : baseHash; return LoadOrCompile(hash, workflowId, () => { Log.Debug("[CSCompiler] Compiling from pre-built CFG: {BlockCount} blocks", cfg.Blocks.Count); var context = new PipelineContext { Script = script }; PrepPubVarNames(context, script); - var pubVarTypes = CFG2CSGenerator.InferPubVarTypes(cfg, script.HelperFunctions, context); - return CFG2CSGenerator.GenerateCompilationUnit(script, cfg, pubVarTypes, hash); + var pubVarTypes = CFG2CSConverter.InferPubVarTypes(cfg, script.HelperFunctions, context); + return CFG2CSConverter.GenerateCompilationUnit(script, cfg, pubVarTypes, hash); }, "CompileFromCFG success for hash", out compileErrors); } @@ -170,7 +170,7 @@ public CSCompiler() // Phase 4: Load into collectible ALContext and instantiate var alc = new CollectibleAssemblyLoadContext(hash); var loadedAssembly = alc.LoadFromStream(assembly); - var typeName = $"KitX.Workflow.BlockScripting.Generated.CompiledScript_{hash}"; + var typeName = $"KitX.Workflow.Compilation.Generated.CompiledScript_{hash}"; var scriptType = loadedAssembly.GetType(typeName); if (scriptType == null) { @@ -240,7 +240,7 @@ public void ClearCache() string.Join(", ", stmt.Arguments), stmt.ConditionPubVar); } - var pubVarTypes = CFG2CSGenerator.InferPubVarTypes(formattedScript, script.HelperFunctions, context); + var pubVarTypes = CFG2CSConverter.InferPubVarTypes(formattedScript, script.HelperFunctions, context); return (formattedScript, pubVarTypes); } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/CSEmitContext.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Compilation/CSEmitContext.cs similarity index 91% rename from KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/CSEmitContext.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Compilation/CSEmitContext.cs index eb40551a..e9ed1222 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/CSEmitContext.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Compilation/CSEmitContext.cs @@ -4,13 +4,13 @@ using KitX.Workflow.CFG; using KitX.Workflow.Conversion; -namespace KitX.Workflow.BlockScripting; +namespace KitX.Workflow.Compilation; /// /// Context passed to during /// CFG→C# generation. Holds the per-script type map and shared emission helpers so each /// builtin descriptor can emit its C# without reimplementing boilerplate, and so -/// dispatches without hardcoding function names. +/// dispatches without hardcoding function names. /// public sealed class CSEmitContext { @@ -38,15 +38,15 @@ public ExpressionSyntax NullLiteral() /// Resolves a formatted argument string into a Roslyn expression. public ExpressionSyntax ResolveArgument(string arg) - => CFG2CSGenerator.ResolveArgumentExpression(arg, PubVarTypes); + => CFG2CSConverter.ResolveArgumentExpression(arg, PubVarTypes); /// Wraps an expression in ConvertTo<T>(...) for typed PubVar assignment. public ExpressionSyntax ConvertTo(string typeName, ExpressionSyntax expr) - => CFG2CSGenerator.BuildConvertToInvocation(typeName, expr); + => CFG2CSConverter.BuildConvertToInvocation(typeName, expr); /// Builds a G.member(args...) invocation expression. public InvocationExpressionSyntax GInvoke(string member, params ExpressionSyntax[] args) - => CFG2CSGenerator.BuildGInvoke(member, args); + => CFG2CSConverter.BuildGInvoke(member, args); /// Builds a G.member(args...) expression statement. public StatementSyntax GInvokeStatement(string member, params ExpressionSyntax[] args) @@ -60,7 +60,7 @@ public StatementSyntax GInvokeStatement(string member, params ExpressionSyntax[] /// or a bare expression statement otherwise. /// public List EmitValueAssignment(string? pubVarTarget, ExpressionSyntax rhs, string sourceType = "object") - => CFG2CSGenerator.BuildValueAssignment(pubVarTarget, rhs, sourceType, PubVarTypes); + => CFG2CSConverter.BuildValueAssignment(pubVarTarget, rhs, sourceType, PubVarTypes); /// Emits var pubVarTarget = init; (inferred-type local declaration). public List EmitVarLocal(string pubVarTarget, ExpressionSyntax init) @@ -95,5 +95,5 @@ public List EmitNextBlockAssignment(string member, params Expre /// G.{Name}(args) (or bare {Name}(args) for helpers) plus value assignment. /// public List EmitDefault(CFGStatement stmt) - => CFG2CSGenerator.EmitDefaultStatements(stmt, this); + => CFG2CSConverter.EmitDefaultStatements(stmt, this); } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/CompiledScriptEntry.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Compilation/CompiledScriptEntry.cs similarity index 97% rename from KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/CompiledScriptEntry.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Compilation/CompiledScriptEntry.cs index 4e98fd92..2eac17f5 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/CompiledScriptEntry.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Compilation/CompiledScriptEntry.cs @@ -1,7 +1,7 @@ using System.Runtime.Loader; using Serilog; -namespace KitX.Workflow.BlockScripting; +namespace KitX.Workflow.Compilation; /// /// Wraps a compiled script instance with its diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/CompiledScriptMeta.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Compilation/CompiledScriptMeta.cs similarity index 88% rename from KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/CompiledScriptMeta.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Compilation/CompiledScriptMeta.cs index ce783c4a..1bb9e62f 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/CompiledScriptMeta.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Compilation/CompiledScriptMeta.cs @@ -1,4 +1,4 @@ -namespace KitX.Workflow.BlockScripting; +namespace KitX.Workflow.Compilation; /// /// Metadata for a persisted compiled script assembly on disk. @@ -24,7 +24,7 @@ internal class CompiledScriptMeta /// /// Full type name of the compiled script class in the assembly, - /// e.g. "KitX.Workflow.BlockScripting.Generated.CompiledScript_1a2b3c4d". + /// e.g. "KitX.Workflow.Compilation.Generated.CompiledScript_1a2b3c4d". /// public string TypeName { get; set; } = string.Empty; } \ No newline at end of file diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/ConstantValueRewriter.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Compilation/ConstantValueRewriter.cs similarity index 99% rename from KitX Clients/KitX Workflow/KitX.Workflow/ConstantValueRewriter.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Compilation/ConstantValueRewriter.cs index fffa8730..2bbc33ea 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/ConstantValueRewriter.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Compilation/ConstantValueRewriter.cs @@ -2,7 +2,7 @@ using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; -namespace KitX.Workflow; +namespace KitX.Workflow.Compilation; /// /// Rewriter for injecting constant values into variable declarations using CSharpSyntaxRewriter diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/ICompiledBlockScript.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Compilation/ICompiledBlockScript.cs similarity index 93% rename from KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/ICompiledBlockScript.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Compilation/ICompiledBlockScript.cs index e50ea607..107d6dbe 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/ICompiledBlockScript.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Compilation/ICompiledBlockScript.cs @@ -1,4 +1,4 @@ -namespace KitX.Workflow.BlockScripting; +namespace KitX.Workflow.Compilation; /// /// Represents a BlockScript compiled into a .NET assembly via Roslyn CSharpCompilation. diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/ScriptCompilationBackend.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Compilation/ScriptCompilationBackend.cs similarity index 96% rename from KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/ScriptCompilationBackend.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Compilation/ScriptCompilationBackend.cs index 028238bb..5718ab0d 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/ScriptCompilationBackend.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Compilation/ScriptCompilationBackend.cs @@ -6,7 +6,7 @@ using KitX.Core.Contract.Workflow; using Serilog; -namespace KitX.Workflow.BlockScripting; +namespace KitX.Workflow.Compilation; /// /// Roslyn compilation backend — compiles @@ -94,9 +94,9 @@ internal static List GetCompilationReferences() var seedAssemblies = new Assembly[] { typeof(BlockScriptExecutionGlobals).Assembly, - typeof(KitX.Workflow.Contract.Models.BlockScript).Assembly, + typeof(KitX.Workflow.Abstractions.Models.BlockScript).Assembly, typeof(ICompiledBlockScript).Assembly, - typeof(KitX.Workflow.Contract.Models.PluginCallInfo).Assembly, + typeof(KitX.Workflow.Abstractions.Models.PluginCallInfo).Assembly, typeof(Microsoft.CSharp.RuntimeBinder.Binder).Assembly, typeof(object).Assembly, typeof(System.Collections.Generic.List<>).Assembly, diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/ScriptPersistenceManager.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Compilation/ScriptPersistenceManager.cs similarity index 99% rename from KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/ScriptPersistenceManager.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Compilation/ScriptPersistenceManager.cs index a5e9ad19..dfb4b1b8 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/ScriptPersistenceManager.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Compilation/ScriptPersistenceManager.cs @@ -1,7 +1,7 @@ using System.Text.Json; using Serilog; -namespace KitX.Workflow.BlockScripting; +namespace KitX.Workflow.Compilation; /// /// Manages disk persistence of compiled script assemblies. diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/IBlockScriptPipelineService.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Contract/IBlockScriptPipelineService.cs deleted file mode 100644 index 13d92353..00000000 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Contract/IBlockScriptPipelineService.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System.Threading; -using System.Threading.Tasks; -using KitX.Core.Contract.Workflow; -using KitX.Workflow.Contract.Models; - -namespace KitX.Workflow.Contract; - -/// -/// Internal pipeline extension of . -/// -/// Carries the BlockScript methods that operate on the parsed BlockScript / -/// BlockScriptParseResult models — these are pipeline-internal and not consumed -/// by the Dashboard, so they are split out of the public Contract interface together -/// with the model types they reference (which also live in this namespace). -/// Implemented by the same BlockScriptServiceImpl that implements -/// IBlockScriptService. -/// -public interface IBlockScriptPipelineService -{ - /// - /// Parses a block script from source code. - /// - BlockScriptParseResult ParseBlockScript(string sourceCode); - - /// - /// Parses a block script from source code asynchronously. - /// - Task ParseBlockScriptAsync(string sourceCode); - - /// - /// Executes an already-parsed block script. - /// - Task ExecuteBlockScriptAsync( - BlockScript script, - System.Collections.Generic.Dictionary? parameters = null, - CancellationToken cancellationToken = default); - - /// - /// Compiles a parsed BlockScript and persists the compiled assembly to disk. - /// - /// The parsed BlockScript to compile. - /// The workflow ID for assembly naming. - /// True if compilation and persistence succeeded. - Task CompileAndPersistAsync(BlockScript script, string workflowId); -} diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFGConditionDuplicator.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFGConditionDuplicator.cs index 6fbd677c..77c1b231 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFGConditionDuplicator.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFGConditionDuplicator.cs @@ -1,6 +1,6 @@ using KitX.Core.Contract.Workflow; -namespace KitX.Workflow.CFG; +namespace KitX.Workflow.Conversion; /// /// Duplicates Loop condition evaluation statements before each ToLoopCond diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/GlobalUsings.cs b/KitX Clients/KitX Workflow/KitX.Workflow/GlobalUsings.cs index 0aab36f1..7ce3bc3a 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/GlobalUsings.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/GlobalUsings.cs @@ -4,5 +4,15 @@ // without each file having to repeat them. global using KitX.Workflow.Hosting; -global using KitX.Workflow.Contract; -global using KitX.Workflow.Contract.Models; +global using KitX.Workflow.Abstractions; +global using KitX.Workflow.Abstractions.Models; +global using KitX.Workflow.Abstractions.Models.Statements; +global using KitX.Workflow.Abstractions.Models.Results; +// Compilation / BlockScripting / Conversion are the three core subsystem namespaces and are +// cross-referenced bidirectionally (e.g. Compilation's CSCompiler uses BlockScripting's +// BuiltinFunctionRegistry and Conversion's CFGPipeline; BlockScripting's BlockScriptExecutor +// uses Compilation's CSCompiler). Declaring them globally avoids per-file using churn. +global using KitX.Workflow.Compilation; +global using KitX.Workflow.BlockScripting; +global using KitX.Workflow.Conversion; +global using KitX.Workflow.CFG; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Hosting/ServiceCollectionExtensions.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Hosting/ServiceCollectionExtensions.cs index dce2b495..bdb111d5 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Hosting/ServiceCollectionExtensions.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Hosting/ServiceCollectionExtensions.cs @@ -41,9 +41,8 @@ public static IServiceCollection AddKitXWorkflow(this IServiceCollection service Log.Information("[DI] IBlockScriptService created with RealPluginManager. HashCode: {HashCode}", rpm.GetHashCode()); return service; }); - // IBlockScriptPipelineService shares the same BlockScriptServiceImpl instance - // (it carries the parsed-model methods split out of the public IBlockScriptService). - services.AddSingleton(sp => (IBlockScriptPipelineService)sp.GetRequiredService()); + // IBlockScriptPipelineService was removed (zero interface-type consumers; + // WorkflowManagementService uses the concrete BlockScriptServiceImpl directly). services.AddSingleton(sp => WorkflowScriptService.PluginServiceInstance); services.AddSingleton(sp => WorkflowScriptService.ManagementServiceInstance); diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/WorkflowScriptService.cs b/KitX Clients/KitX Workflow/KitX.Workflow/WorkflowScriptService.cs index 1b9592cd..ca4271bf 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/WorkflowScriptService.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/WorkflowScriptService.cs @@ -1,5 +1,4 @@ using KitX.Core.Contract.Workflow; -using KitX.Workflow.Contract; using KitX.Workflow.Hosting; using KitX.Shared.CSharp.Plugin; using Serilog; @@ -7,194 +6,71 @@ namespace KitX.Workflow; /// -/// Facade service that delegates to specialized workflow services. -/// Exposes the public workflow interfaces (IWorkflowManagementService, -/// IWorkflowPluginService, IBlockScriptService) plus the internal -/// IBlockScriptPipelineService for backward compatibility. +/// Static factory container for the workflow service graph. +/// +/// Formerly an aggregate facade that implemented IWorkflowManagementService / +/// IWorkflowPluginService / IBlockScriptService / IBlockScriptPipelineService by forwarding +/// to the real implementations. Those interface implementations were dead code — DI never +/// resolved WorkflowScriptService itself as any of those interfaces (it registered +/// BlockScriptServiceImpl for IBlockScriptService, and the management/plugin services via the +/// static factory properties below). The facade interface implementations have been removed; +/// what remains is the static service-graph factory that the DI lambdas in +/// ServiceCollectionExtensions still reference (RuntimeState / PluginServiceInstance / +/// ManagementServiceInstance). /// -public class WorkflowScriptService : IWorkflowManagementService, - IWorkflowPluginService, IBlockScriptService, IBlockScriptPipelineService +public class WorkflowScriptService { /// - /// Gets the singleton facade instance. - /// WorkflowScriptService is a facade that delegates to its static service graph, - /// so it's not resolved from DI (IBlockScriptService is registered as BlockScriptServiceImpl). - /// Use ServiceLocator.GetRequiredService<IWorkflowManagementService>() etc. for individual interfaces. + /// Shared runtime state across all workflow services. Registered in DI as a singleton + /// and also exposed here for the static factory properties below. /// - public static WorkflowScriptService Instance { get; } = new(); + private static readonly WorkflowRuntimeState SharedState = new(); /// - /// Shared runtime state across all workflow services. + /// The plugin service — built once (depends only on SharedState). /// - private static readonly WorkflowRuntimeState SharedState = new(); + private static readonly IWorkflowPluginService PluginService = new WorkflowPluginService(SharedState); /// - /// Service graph — built once at singleton instantiation. - /// Held as the concrete impl so both IBlockScriptService (public) and - /// IBlockScriptPipelineService (internal) methods are reachable. + /// The management service — lazily built once BlockScriptService is available. /// - private static BlockScriptServiceImpl? _blockScriptService; private static IWorkflowManagementService? _managementService; - private static readonly IWorkflowPluginService PluginService; - /// - /// Static constructor initializes the service graph in dependency order. - /// Note: BlockScriptService is NOT initialized here to avoid accessing it before - /// PreResolvedRealPluginManager is set. BlockScriptService is fully lazy-initialized - /// via its property getter. - /// static WorkflowScriptService() { - // 1. PluginService (depends only on SharedState) - PluginService = new WorkflowPluginService(SharedState); - - // 2. ManagementService - will be initialized lazily when first accessed - // BlockScriptService is also lazy-initialized, so ManagementService should not - // be created here to avoid using a partially initialized BlockScriptService - } - - /// - /// Gets the BlockScriptService, creating it lazily once RealPluginManager is available. - /// This ensures the BlockScriptService always has a valid RealPluginManager. - /// - private static BlockScriptServiceImpl BlockScriptService - { - get - { - if (_blockScriptService == null) - { - RealPluginManager? rpm = null; - try - { - rpm = ServiceLocator.GetRequiredService(); - if (rpm != null) - { - SharedState.IsParserInitialized = true; - Log.Information("[WorkflowScriptService] Real plugin manager obtained. HashCode: {HashCode}", rpm.GetHashCode()); - } - } - catch (Exception ex) - { - Log.Error(ex, "[WorkflowScriptService] Failed to get RealPluginManager"); - } - - _blockScriptService = new BlockScriptServiceImpl(SharedState, rpm); - Log.Information("[WorkflowScriptService] BlockScriptService created with RealPluginManager. HashCode: {HashCode}", rpm?.GetHashCode()); - } - return _blockScriptService; - } + // PluginService initialized inline above. ManagementService is lazy (see property). } - /// - /// Exposes the shared runtime state for DI registration. - /// + /// Exposes the shared runtime state for DI registration. internal static WorkflowRuntimeState RuntimeState => SharedState; - /// - /// Exposes IBlockScriptService for DI registration. - /// - internal static IBlockScriptService BlockScriptServiceInstance => BlockScriptService; - - /// - /// Exposes IWorkflowPluginService for DI registration. - /// + /// Exposes IWorkflowPluginService for DI registration. internal static IWorkflowPluginService PluginServiceInstance => PluginService; /// /// Gets the ManagementService, creating it lazily once BlockScriptService is available. + /// BlockScriptServiceImpl is resolved from DI by the caller (ServiceCollectionExtensions), + /// but the management service here is built against the static SharedState + the + /// BlockScriptServiceImpl that DI constructs — wired via ManagementServiceInstance. /// private static IWorkflowManagementService ManagementService => - _managementService ??= new WorkflowManagementService(SharedState, BlockScriptService); + _managementService ??= new WorkflowManagementService(SharedState, ResolveBlockScriptServiceImpl()); - /// - /// Exposes IWorkflowManagementService for DI registration. - /// + /// Exposes IWorkflowManagementService for DI registration. internal static IWorkflowManagementService ManagementServiceInstance => ManagementService; - private WorkflowScriptService() + /// + /// Resolves the DI-registered BlockScriptServiceImpl. The DI factory in + /// ServiceCollectionExtensions constructs it with RealPluginManager; here we fetch the + /// IBlockScriptService that DI registered (which is that same BlockScriptServiceImpl). + /// + private static BlockScriptServiceImpl ResolveBlockScriptServiceImpl() { - // Private constructor to enforce singleton usage. + // The DI-registered IBlockScriptService is a BlockScriptServiceImpl instance; cast to + // reach the parsed-model methods that WorkflowManagementService needs. + var bss = (BlockScriptServiceImpl)ServiceLocator.GetRequiredService(); + return bss; } - // --- IWorkflowManagementService --- - - public IReadOnlyList GetWorkflows() => ManagementService.GetWorkflows(); - - public void AddWorkflow(IWorkflowCase workflow) => ManagementService.AddWorkflow(workflow); - - public void RemoveWorkflow(string workflowId) => ManagementService.RemoveWorkflow(workflowId); - - public Task RunWorkflowAsync(string workflowId) => ManagementService.RunWorkflowAsync(workflowId); - - public Task RunWorkflowWithDetailsAsync(string workflowId) - => ManagementService.RunWorkflowWithDetailsAsync(workflowId); - - public Task StopWorkflowAsync(string workflowId) => ManagementService.StopWorkflowAsync(workflowId); - - public Task CompileAndPersistWorkflowAsync(string workflowId) => - ManagementService.CompileAndPersistWorkflowAsync(workflowId); - - // --- IWorkflowPluginService --- - - public void InitializePluginManager() => PluginService.InitializePluginManager(); - - public void UpdateAvailablePlugins(List plugins) => - PluginService.UpdateAvailablePlugins(plugins); - - public List ParseConstantsFromCode(string code) => - PluginService.ParseConstantsFromCode(code); - - public string ApplyConstantsToCode(string code, List constants) => - PluginService.ApplyConstantsToCode(code, constants); - - public string MergeHelperFunctions(string mainCode, List helperFunctions) => - PluginService.MergeHelperFunctions(mainCode, helperFunctions); - - // --- IBlockScriptService (public: source-string based) --- - - public BlockScriptValidationResult ValidateBlockScript(string sourceCode) => - BlockScriptService.ValidateBlockScript(sourceCode); - - public List ParseConstantsFromBlockScript(string sourceCode) => - BlockScriptService.ParseConstantsFromBlockScript(sourceCode); - - public Task ExecuteBlockScriptAsync( - string sourceCode, - Dictionary? parameters = null, - CancellationToken cancellationToken = default) => - BlockScriptService.ExecuteBlockScriptAsync(sourceCode, parameters, cancellationToken); - - public Task ExecuteBlockScriptAsync( - string sourceCode, - List helperFunctions, - CancellationToken cancellationToken = default) => - BlockScriptService.ExecuteBlockScriptAsync(sourceCode, helperFunctions, cancellationToken); - - public Task ExecuteBlockScriptAsync( - string sourceCode, - List helperFunctions, - Dictionary? constantOverrides, - CancellationToken cancellationToken = default) => - BlockScriptService.ExecuteBlockScriptAsync(sourceCode, helperFunctions, constantOverrides, cancellationToken); - - public int PreloadCompiledScripts(string workflowId) => - BlockScriptService.PreloadCompiledScripts(workflowId); - - // --- IBlockScriptPipelineService (internal: parsed-model based) --- - - public BlockScriptParseResult ParseBlockScript(string sourceCode) => - BlockScriptService.ParseBlockScript(sourceCode); - - public Task ParseBlockScriptAsync(string sourceCode) => - BlockScriptService.ParseBlockScriptAsync(sourceCode); - - public Task ExecuteBlockScriptAsync( - BlockScript script, - Dictionary? parameters = null, - CancellationToken cancellationToken = default) => - BlockScriptService.ExecuteBlockScriptAsync(script, parameters, cancellationToken); - - public Task CompileAndPersistAsync(BlockScript script, string workflowId) => - BlockScriptService.CompileAndPersistAsync(script, workflowId); -} - + private WorkflowScriptService() { } +} \ No newline at end of file diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/WorkflowStorageService.cs b/KitX Clients/KitX Workflow/KitX.Workflow/WorkflowStorageService.cs index df75bb1f..022234a1 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/WorkflowStorageService.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/WorkflowStorageService.cs @@ -217,7 +217,7 @@ public async Task PreloadCompiledScriptsAsync() var workflows = await DiscoverWorkflowsAsync(); var totalLoaded = 0; - var scriptService = WorkflowScriptService.Instance; + var scriptService = ServiceLocator.GetRequiredService(); foreach (var workflow in workflows) { From 42a172f47aa5b6215cfdd5b23a97e1c22c9b7fe0 Mon Sep 17 00:00:00 2001 From: StarInk Date: Sat, 20 Jun 2026 10:00:34 +0200 Subject: [PATCH 102/110] =?UTF-8?q?=F0=9F=A7=A9=20Refactor(Workflow):=20?= =?UTF-8?q?=E5=A4=9A=E7=B1=BB=E5=9E=8B=E6=96=87=E4=BB=B6=E6=8B=86=E5=88=86?= =?UTF-8?q?-=E4=BB=A3=E7=A0=81=E7=BB=84=E7=BB=87=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E9=98=B6=E6=AE=B5L10?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 11 个一文件多类型文件按职责拆分为独立单类型文件,namespace 与逻辑保持不变,纯机械拆分不改方法体。 拆分清单: - KcsFileService.cs -> + StrictScriptValidator.cs - BlockScripting/BlockScopeManager.cs -> + BlockScripting/BlockScope.cs - BlockScripting/BlockScriptParser.cs -> + BlockScripting/BlockLinker.cs - BlockScripting/BlockStatementExtractor.cs -> + BlockScripting/RoslynExtensions.cs - BlockScripting/BlockStructureRecognizer.cs -> + BlockScripting/RecognizedBlock.cs - BlockScripting/BlockSyntaxValidator.cs -> + BlockScripting/BlockValidationResult.cs - Compilation/CompiledScriptEntry.cs -> + Compilation/CollectibleAssemblyLoadContext.cs - Abstractions/IBlockScopeManager.cs -> + Abstractions/IBlockScope.cs - Abstractions/IBlueprintConverters.cs -> + Abstractions/IBlueprintToBlockScriptConverter.cs - Abstractions/INodeExportHelper.cs -> + Abstractions/OutputArmDescriptor.cs - Abstractions/Models/ConversionDiagnostics.cs -> + Abstractions/Models/ConversionDiag.cs (enum+record) 排除(按 KISS 约束): - CFG/*Block.cs 等:枚举搭主类为 C# 惯用法,不拆 - BuiltinFunctions/*.cs:partial BlockScriptExecutionGlobals 扩展为惯用法,不拆 - Blueprint/LayoutService.cs:LayoutRegion/LinearRegion/ForkRegion 为 private nested 紧耦合实现细节,拆开需改可见性反而破坏封装,不拆 验证:4项目 build 0 错误 + 全测试 PASS(A-W)。 Date: 2026-06-20 Author: StarInk --- .../KitX.Workflow/Abstractions/IBlockScope.cs | 40 ++++++++++ .../Abstractions/IBlockScopeManager.cs | 37 --------- .../Abstractions/IBlueprintConverters.cs | 21 ----- .../IBlueprintToBlockScriptConverter.cs | 22 ++++++ .../Abstractions/INodeExportHelper.cs | 18 ----- .../Abstractions/Models/ConversionDiag.cs | 22 ++++++ .../Models/ConversionDiagnostics.cs | 21 ----- .../Abstractions/OutputArmDescriptor.cs | 19 +++++ .../BlockScripting/BlockLinker.cs | 41 ++++++++++ .../BlockScripting/BlockScope.cs | 61 ++++++++++++++ .../BlockScripting/BlockScopeManager.cs | 60 -------------- .../BlockScripting/BlockScriptParser.cs | 38 --------- .../BlockScripting/BlockStatementExtractor.cs | 16 ---- .../BlockStructureRecognizer.cs | 13 --- .../BlockScripting/BlockSyntaxValidator.cs | 11 --- .../BlockScripting/BlockValidationResult.cs | 14 ++++ .../BlockScripting/RecognizedBlock.cs | 16 ++++ .../BlockScripting/RoslynExtensions.cs | 19 +++++ .../CollectibleAssemblyLoadContext.cs | 16 ++++ .../Compilation/CompiledScriptEntry.cs | 13 --- .../KitX.Workflow/KcsFileService.cs | 77 ------------------ .../KitX.Workflow/StrictScriptValidator.cs | 79 +++++++++++++++++++ 22 files changed, 349 insertions(+), 325 deletions(-) create mode 100644 KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/IBlockScope.cs create mode 100644 KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/IBlueprintToBlockScriptConverter.cs create mode 100644 KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/ConversionDiag.cs create mode 100644 KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/OutputArmDescriptor.cs create mode 100644 KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockLinker.cs create mode 100644 KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScope.cs create mode 100644 KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockValidationResult.cs create mode 100644 KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/RecognizedBlock.cs create mode 100644 KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/RoslynExtensions.cs create mode 100644 KitX Clients/KitX Workflow/KitX.Workflow/Compilation/CollectibleAssemblyLoadContext.cs create mode 100644 KitX Clients/KitX Workflow/KitX.Workflow/StrictScriptValidator.cs diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/IBlockScope.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/IBlockScope.cs new file mode 100644 index 00000000..cb2b0569 --- /dev/null +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/IBlockScope.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; + +namespace KitX.Workflow.Abstractions; + +/// +/// Variable scope interface. +/// Internal to the workflow pipeline. +/// +public interface IBlockScope +{ + /// + /// Name of the block this scope belongs to + /// + string BlockName { get; } + + /// + /// Whether this is the global scope + /// + bool IsGlobal { get; } + + /// + /// Gets a variable value + /// + object? GetVariable(string name); + + /// + /// Sets a variable value + /// + void SetVariable(string name, object? value); + + /// + /// Checks if a variable exists in this scope + /// + bool HasVariable(string name); + + /// + /// Gets all variables in this scope + /// + Dictionary GetAllVariables(); +} \ No newline at end of file diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/IBlockScopeManager.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/IBlockScopeManager.cs index 49586776..e2184e74 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/IBlockScopeManager.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/IBlockScopeManager.cs @@ -32,41 +32,4 @@ public interface IBlockScopeManager /// Clears all local scopes (called between executions) /// void ClearLocalScopes(); -} - -/// -/// Variable scope interface. -/// Internal to the workflow pipeline. -/// -public interface IBlockScope -{ - /// - /// Name of the block this scope belongs to - /// - string BlockName { get; } - - /// - /// Whether this is the global scope - /// - bool IsGlobal { get; } - - /// - /// Gets a variable value - /// - object? GetVariable(string name); - - /// - /// Sets a variable value - /// - void SetVariable(string name, object? value); - - /// - /// Checks if a variable exists in this scope - /// - bool HasVariable(string name); - - /// - /// Gets all variables in this scope - /// - Dictionary GetAllVariables(); } \ No newline at end of file diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/IBlueprintConverters.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/IBlueprintConverters.cs index f973a1ed..9d1f4651 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/IBlueprintConverters.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/IBlueprintConverters.cs @@ -23,24 +23,3 @@ public interface IBlockScriptToBlueprintConverter /// Converted Blueprint KitX.Core.Contract.Workflow.Blueprint Convert(KitX.Workflow.Abstractions.Models.BlockScript script); } - -/// -/// Interface for converting Blueprint to BlockScript. -/// Internal to the workflow pipeline (consumed by BlueprintService). -/// -public interface IBlueprintToBlockScriptConverter -{ - /// - /// Convert Blueprint to BlockScript source code - /// - /// Blueprint to convert - /// BlockScript source code - string Convert(KitX.Core.Contract.Workflow.Blueprint blueprint); - - /// - /// Convert Blueprint to parsed BlockScript - /// - /// Blueprint to convert - /// Parsed BlockScript - KitX.Workflow.Abstractions.Models.BlockScript ConvertToBlockScript(KitX.Core.Contract.Workflow.Blueprint blueprint); -} diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/IBlueprintToBlockScriptConverter.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/IBlueprintToBlockScriptConverter.cs new file mode 100644 index 00000000..72140b3c --- /dev/null +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/IBlueprintToBlockScriptConverter.cs @@ -0,0 +1,22 @@ +namespace KitX.Workflow.Abstractions; + +/// +/// Interface for converting Blueprint to BlockScript. +/// Internal to the workflow pipeline (consumed by BlueprintService). +/// +public interface IBlueprintToBlockScriptConverter +{ + /// + /// Convert Blueprint to BlockScript source code + /// + /// Blueprint to convert + /// BlockScript source code + string Convert(KitX.Core.Contract.Workflow.Blueprint blueprint); + + /// + /// Convert Blueprint to parsed BlockScript + /// + /// Blueprint to convert + /// Parsed BlockScript + KitX.Workflow.Abstractions.Models.BlockScript ConvertToBlockScript(KitX.Core.Contract.Workflow.Blueprint blueprint); +} \ No newline at end of file diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/INodeExportHelper.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/INodeExportHelper.cs index 0e187f20..85238c25 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/INodeExportHelper.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/INodeExportHelper.cs @@ -36,22 +36,4 @@ public interface INodeExportHelper /// Returns true if the given output pin is consumed by at least one data connection. /// bool IsOutputConsumed(ContractWorkflow.BlueprintNode node, string pinName); -} - -/// -/// Describes a single output arm of a control flow node (e.g., Branch's True/False, Loop's -/// LoopBody/LoopEnd). Internal to the workflow pipeline. Returned by -/// . -/// -public struct OutputArmDescriptor -{ - /// - /// The output pin name (e.g., Pins.True, Pins.False, Pins.LoopBody, Pins.LoopEnd) - /// - public string PinName { get; set; } - - /// - /// Whether this arm loops back to a parent node (LoopBody loops back to the Loop node) - /// - public bool IsLoopback { get; set; } } \ No newline at end of file diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/ConversionDiag.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/ConversionDiag.cs new file mode 100644 index 00000000..8ba02eca --- /dev/null +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/ConversionDiag.cs @@ -0,0 +1,22 @@ +namespace KitX.Workflow.Abstractions.Models; + +/// +/// Severity of a conversion/parse diagnostic. Mirrors the compile-path model so the +/// Dashboard editor output panel can render conversion problems the same way it renders +/// Roslyn compile errors. +/// +public enum ConversionDiagSeverity +{ + /// Blocks conversion from producing correct output; should surface to the user. + Error, + + /// Non-fatal (e.g. dead code per BlockScript §6); surfaced as a warning. + Warning, +} + +/// +/// A single user-facing diagnostic produced during BlockScript parsing or BS↔BP conversion. +/// Stable lets the editor/UI filter and deduplicate. +/// is 1-based, matching . +/// +public sealed record ConversionDiag(ConversionDiagSeverity Severity, string Code, string Message, int? LineNumber = null); \ No newline at end of file diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/ConversionDiagnostics.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/ConversionDiagnostics.cs index 71fd62c3..89519b8b 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/ConversionDiagnostics.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/ConversionDiagnostics.cs @@ -4,27 +4,6 @@ namespace KitX.Workflow.Abstractions.Models; -/// -/// Severity of a conversion/parse diagnostic. Mirrors the compile-path model so the -/// Dashboard editor output panel can render conversion problems the same way it renders -/// Roslyn compile errors. -/// -public enum ConversionDiagSeverity -{ - /// Blocks conversion from producing correct output; should surface to the user. - Error, - - /// Non-fatal (e.g. dead code per BlockScript §6); surfaced as a warning. - Warning, -} - -/// -/// A single user-facing diagnostic produced during BlockScript parsing or BS↔BP conversion. -/// Stable lets the editor/UI filter and deduplicate. -/// is 1-based, matching . -/// -public sealed record ConversionDiag(ConversionDiagSeverity Severity, string Code, string Message, int? LineNumber = null); - /// /// Collects user-facing diagnostics across the parse + conversion pipeline, mirroring the /// out IReadOnlyList<string> errors channel already used by the Roslyn compile path. diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/OutputArmDescriptor.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/OutputArmDescriptor.cs new file mode 100644 index 00000000..5bdde921 --- /dev/null +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/OutputArmDescriptor.cs @@ -0,0 +1,19 @@ +namespace KitX.Workflow.Abstractions; + +/// +/// Describes a single output arm of a control flow node (e.g., Branch's True/False, Loop's +/// LoopBody/LoopEnd). Internal to the workflow pipeline. Returned by +/// . +/// +public struct OutputArmDescriptor +{ + /// + /// The output pin name (e.g., Pins.True, Pins.False, Pins.LoopBody, Pins.LoopEnd) + /// + public string PinName { get; set; } + + /// + /// Whether this arm loops back to a parent node (LoopBody loops back to the Loop node) + /// + public bool IsLoopback { get; set; } +} \ No newline at end of file diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockLinker.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockLinker.cs new file mode 100644 index 00000000..1bc6b43d --- /dev/null +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockLinker.cs @@ -0,0 +1,41 @@ +using KitX.Core.Contract.Workflow; + +namespace KitX.Workflow.BlockScripting; + +/// +/// Links blocks sequentially for natural fallthrough execution flow +/// +internal static class BlockLinker +{ + public static void LinkBlocksSequentially(BlockScript script) + { + var blocks = script.AllBlocks; + + for (int i = 0; i < blocks.Count - 1; i++) + { + var currentBlock = blocks[i]; + var nextBlock = blocks[i + 1]; + + // Skip if already has explicit control flow target + if (!string.IsNullOrEmpty(currentBlock.NextBlockName)) + continue; + + // Skip LoopBlocks (they have their own flow control) + if (currentBlock.Type == BlockType.LoopBlock) + continue; + + // Don't link across block scope boundaries that have flow control + // A block ending with Branch/Loop should NOT fall through + var lastStatement = currentBlock.Statements.LastOrDefault(); + if (lastStatement is FlowControlStatement flowStmt && + (flowStmt.ControlType == FlowControlType.Branch || + flowStmt.ControlType == FlowControlType.Loop || + flowStmt.ControlType == FlowControlType.Return)) + { + continue; + } + + currentBlock.NextBlockName = nextBlock.Name; + } + } +} \ No newline at end of file diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScope.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScope.cs new file mode 100644 index 00000000..b6264e73 --- /dev/null +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScope.cs @@ -0,0 +1,61 @@ +namespace KitX.Workflow.BlockScripting; + +/// +/// Variable scope implementation +/// +public class BlockScope : IBlockScope +{ + private readonly Dictionary _variables = new(); + private readonly bool _isGlobal; + + /// + /// Creates a new block scope + /// + public BlockScope(string blockName, bool isGlobal) + { + BlockName = blockName; + _isGlobal = isGlobal; + } + + /// + /// Name of the block this scope belongs to + /// + public string BlockName { get; } + + /// + /// Whether this is the global scope + /// + public bool IsGlobal => _isGlobal; + + /// + /// Gets a variable value + /// + public object? GetVariable(string name) + { + return _variables.TryGetValue(name, out var value) ? value : null; + } + + /// + /// Sets a variable value + /// + public void SetVariable(string name, object? value) + { + _variables[name] = value; + } + + /// + /// Checks if a variable exists in this scope + /// + public bool HasVariable(string name) + { + return _variables.ContainsKey(name); + } + + /// + /// Gets all variables in this scope + /// + public Dictionary GetAllVariables() + { + return new Dictionary(_variables); + } +} \ No newline at end of file diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScopeManager.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScopeManager.cs index ec47f037..c736483b 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScopeManager.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScopeManager.cs @@ -109,63 +109,3 @@ public void InitializeGlobalScope(BlockScript script) } } } - -/// -/// Variable scope implementation -/// -public class BlockScope : IBlockScope -{ - private readonly Dictionary _variables = new(); - private readonly bool _isGlobal; - - /// - /// Creates a new block scope - /// - public BlockScope(string blockName, bool isGlobal) - { - BlockName = blockName; - _isGlobal = isGlobal; - } - - /// - /// Name of the block this scope belongs to - /// - public string BlockName { get; } - - /// - /// Whether this is the global scope - /// - public bool IsGlobal => _isGlobal; - - /// - /// Gets a variable value - /// - public object? GetVariable(string name) - { - return _variables.TryGetValue(name, out var value) ? value : null; - } - - /// - /// Sets a variable value - /// - public void SetVariable(string name, object? value) - { - _variables[name] = value; - } - - /// - /// Checks if a variable exists in this scope - /// - public bool HasVariable(string name) - { - return _variables.ContainsKey(name); - } - - /// - /// Gets all variables in this scope - /// - public Dictionary GetAllVariables() - { - return new Dictionary(_variables); - } -} diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptParser.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptParser.cs index 8c833756..98564ec6 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptParser.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptParser.cs @@ -212,41 +212,3 @@ public Task ParseAsync(string sourceCode) return Task.FromResult(Parse(sourceCode)); } } - -/// -/// Links blocks sequentially for natural fallthrough execution flow -/// -internal static class BlockLinker -{ - public static void LinkBlocksSequentially(BlockScript script) - { - var blocks = script.AllBlocks; - - for (int i = 0; i < blocks.Count - 1; i++) - { - var currentBlock = blocks[i]; - var nextBlock = blocks[i + 1]; - - // Skip if already has explicit control flow target - if (!string.IsNullOrEmpty(currentBlock.NextBlockName)) - continue; - - // Skip LoopBlocks (they have their own flow control) - if (currentBlock.Type == BlockType.LoopBlock) - continue; - - // Don't link across block scope boundaries that have flow control - // A block ending with Branch/Loop should NOT fall through - var lastStatement = currentBlock.Statements.LastOrDefault(); - if (lastStatement is FlowControlStatement flowStmt && - (flowStmt.ControlType == FlowControlType.Branch || - flowStmt.ControlType == FlowControlType.Loop || - flowStmt.ControlType == FlowControlType.Return)) - { - continue; - } - - currentBlock.NextBlockName = nextBlock.Name; - } - } -} diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockStatementExtractor.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockStatementExtractor.cs index 4ee6549b..35efeb69 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockStatementExtractor.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockStatementExtractor.cs @@ -290,19 +290,3 @@ private static ExpressionStatement BuildExpressionStatement( AssignedVariable = assignedVar }; } - -/// -/// Shared Roslyn syntax extensions for workflow parsing -/// -internal static class RoslynExtensions -{ - /// - /// Gets the 1-based line number for a syntax node - /// - public static int GetLineNumber(this SyntaxNode node) - { - var location = node.GetLocation(); - var lineSpan = location.GetLineSpan(); - return lineSpan.StartLinePosition.Line + 1; - } -} diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockStructureRecognizer.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockStructureRecognizer.cs index d4e04170..d994161b 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockStructureRecognizer.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockStructureRecognizer.cs @@ -179,16 +179,3 @@ private string ExtractPureCode(int start, int end) return result; } } - -/// -/// Represents a recognized block with its type, name, and source position -/// -internal class RecognizedBlock -{ - public BlockType BlockType { get; set; } - public string BlockName { get; set; } = string.Empty; - public int StartLine { get; set; } - public int ContentStart { get; set; } - public int ContentEnd { get; set; } - public string Content { get; set; } = string.Empty; -} diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockSyntaxValidator.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockSyntaxValidator.cs index 4be34923..2861e600 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockSyntaxValidator.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockSyntaxValidator.cs @@ -179,14 +179,3 @@ child is DoStatementSyntax || return false; } } - -/// -/// Result of validating a block's C# code -/// -internal class BlockValidationResult -{ - public bool IsValid { get; set; } = true; - public string ErrorMessage { get; set; } = string.Empty; - public int ErrorLine { get; set; } - public SyntaxNode? ParsedRoot { get; set; } -} diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockValidationResult.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockValidationResult.cs new file mode 100644 index 00000000..83c05ffe --- /dev/null +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockValidationResult.cs @@ -0,0 +1,14 @@ +using Microsoft.CodeAnalysis; + +namespace KitX.Workflow.BlockScripting; + +/// +/// Result of validating a block's C# code +/// +internal class BlockValidationResult +{ + public bool IsValid { get; set; } = true; + public string ErrorMessage { get; set; } = string.Empty; + public int ErrorLine { get; set; } + public SyntaxNode? ParsedRoot { get; set; } +} \ No newline at end of file diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/RecognizedBlock.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/RecognizedBlock.cs new file mode 100644 index 00000000..749fa179 --- /dev/null +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/RecognizedBlock.cs @@ -0,0 +1,16 @@ +using KitX.Core.Contract.Workflow; + +namespace KitX.Workflow.BlockScripting; + +/// +/// Represents a recognized block with its type, name, and source position +/// +internal class RecognizedBlock +{ + public BlockType BlockType { get; set; } + public string BlockName { get; set; } = string.Empty; + public int StartLine { get; set; } + public int ContentStart { get; set; } + public int ContentEnd { get; set; } + public string Content { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/RoslynExtensions.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/RoslynExtensions.cs new file mode 100644 index 00000000..4f5c5406 --- /dev/null +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/RoslynExtensions.cs @@ -0,0 +1,19 @@ +using Microsoft.CodeAnalysis; + +namespace KitX.Workflow.BlockScripting; + +/// +/// Shared Roslyn syntax extensions for workflow parsing +/// +internal static class RoslynExtensions +{ + /// + /// Gets the 1-based line number for a syntax node + /// + public static int GetLineNumber(this SyntaxNode node) + { + var location = node.GetLocation(); + var lineSpan = location.GetLineSpan(); + return lineSpan.StartLinePosition.Line + 1; + } +} \ No newline at end of file diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Compilation/CollectibleAssemblyLoadContext.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Compilation/CollectibleAssemblyLoadContext.cs new file mode 100644 index 00000000..d5c85b5f --- /dev/null +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Compilation/CollectibleAssemblyLoadContext.cs @@ -0,0 +1,16 @@ +using System.Runtime.Loader; + +namespace KitX.Workflow.Compilation; + +/// +/// A collectible that allows compiled script assemblies +/// to be unloaded after use, preventing memory leaks during long-running workflow sessions. +/// +internal class CollectibleAssemblyLoadContext : AssemblyLoadContext +{ + /// + /// Initializes a new collectible assembly load context. + /// + /// The name for this context. + public CollectibleAssemblyLoadContext(string name) : base(name, isCollectible: true) { } +} \ No newline at end of file diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Compilation/CompiledScriptEntry.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Compilation/CompiledScriptEntry.cs index 2eac17f5..9e396ebf 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Compilation/CompiledScriptEntry.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Compilation/CompiledScriptEntry.cs @@ -47,16 +47,3 @@ public void Unload() } } } - -/// -/// A collectible that allows compiled script assemblies -/// to be unloaded after use, preventing memory leaks during long-running workflow sessions. -/// -internal class CollectibleAssemblyLoadContext : AssemblyLoadContext -{ - /// - /// Initializes a new collectible assembly load context. - /// - /// The name for this context. - public CollectibleAssemblyLoadContext(string name) : base(name, isCollectible: true) { } -} diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/KcsFileService.cs b/KitX Clients/KitX Workflow/KitX.Workflow/KcsFileService.cs index ea0ac209..e8f35601 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/KcsFileService.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/KcsFileService.cs @@ -1,9 +1,6 @@ using System.Text.Json; using KitX.Core.Contract.Workflow; using Serilog; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; namespace KitX.Workflow; @@ -54,77 +51,3 @@ public async System.Threading.Tasks.Task SaveKcsFileAsync(string filePath, KcsFi } } } - -/// -/// 严格的脚本验证器 - 使用 CSharpSyntaxWalker 检查允许的语法 -/// -internal class StrictScriptValidator : CSharpSyntaxWalker -{ - public bool IsValid { get; private set; } = true; - public string ForbiddenReason { get; private set; } = ""; - - public override void Visit(SyntaxNode? node) - { - if (!IsValid || node == null) return; - - // 1. 允许的基础结构 - if (node is CompilationUnitSyntax || // 脚本根节点 - node is GlobalStatementSyntax || // 全局语句 - node is ExpressionStatementSyntax) // 表达式语句(如 a = 1; 或 Func();) - { - base.Visit(node); - return; - } - - // 2. 允许:变量定义与赋值 - // 包括:int a = 1; (LocalDeclaration) 和 a = 2; (AssignmentExpression) - if (node is LocalDeclarationStatementSyntax || - node is VariableDeclarationSyntax || - node is VariableDeclaratorSyntax || - node is EqualsValueClauseSyntax || // 变量初始化 = 值 - node is AssignmentExpressionSyntax || - node is IdentifierNameSyntax || // 变量名 - node is PredefinedTypeSyntax || // 基本类型关键字 (int, string) - node is LiteralExpressionSyntax) // 字面量 (123, "hello") - { - base.Visit(node); - return; - } - - // 3. 允许:函数调用 - // 包括:MyFunc(a, 10); - if (node is InvocationExpressionSyntax || - node is ArgumentListSyntax || - node is ArgumentSyntax || - node is MemberAccessExpressionSyntax || // 成员访问,如 obj.Member - node is MemberBindingExpressionSyntax || // 成员绑定,如 ?.Member - node is InterpolatedStringExpressionSyntax || // 字符串插值,如 $"Hello {name}" - node is InterpolatedStringTextSyntax || // 字符串插值文本部分 - node is InterpolationSyntax) // 插值表达式 {xxx} - { - base.Visit(node); - return; - } - - // 4. 允许:return 语句 // 不再允许,因为主程序的return语句没有存在的意义 - // if (node is ReturnStatementSyntax) - // { - // base.Visit(node); - // return; - // } - - // 5. 允许:类型转换 - if (node is CastExpressionSyntax) - { - base.Visit(node); - return; - } - - // 6. 禁止一切其他行为 - // 此时 node 可能是 BinaryExpressionSyntax (加减乘除), IfStatementSyntax (条件), WhileStatementSyntax (循环) 等 - IsValid = false; - var location = node.GetLocation(); - var lineSpan = location.GetLineSpan(); - ForbiddenReason = $"Unsupported syntax: {node.Kind()} at line {lineSpan.StartLinePosition.Line + 1}"; - } -} diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/StrictScriptValidator.cs b/KitX Clients/KitX Workflow/KitX.Workflow/StrictScriptValidator.cs new file mode 100644 index 00000000..be033bc6 --- /dev/null +++ b/KitX Clients/KitX Workflow/KitX.Workflow/StrictScriptValidator.cs @@ -0,0 +1,79 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace KitX.Workflow; + +/// +/// 严格的脚本验证器 - 使用 CSharpSyntaxWalker 检查允许的语法 +/// +internal class StrictScriptValidator : CSharpSyntaxWalker +{ + public bool IsValid { get; private set; } = true; + public string ForbiddenReason { get; private set; } = ""; + + public override void Visit(SyntaxNode? node) + { + if (!IsValid || node == null) return; + + // 1. 允许的基础结构 + if (node is CompilationUnitSyntax || // 脚本根节点 + node is GlobalStatementSyntax || // 全局语句 + node is ExpressionStatementSyntax) // 表达式语句(如 a = 1; 或 Func();) + { + base.Visit(node); + return; + } + + // 2. 允许:变量定义与赋值 + // 包括:int a = 1; (LocalDeclaration) 和 a = 2; (AssignmentExpression) + if (node is LocalDeclarationStatementSyntax || + node is VariableDeclarationSyntax || + node is VariableDeclaratorSyntax || + node is EqualsValueClauseSyntax || // 变量初始化 = 值 + node is AssignmentExpressionSyntax || + node is IdentifierNameSyntax || // 变量名 + node is PredefinedTypeSyntax || // 基本类型关键字 (int, string) + node is LiteralExpressionSyntax) // 字面量 (123, "hello") + { + base.Visit(node); + return; + } + + // 3. 允许:函数调用 + // 包括:MyFunc(a, 10); + if (node is InvocationExpressionSyntax || + node is ArgumentListSyntax || + node is ArgumentSyntax || + node is MemberAccessExpressionSyntax || // 成员访问,如 obj.Member + node is MemberBindingExpressionSyntax || // 成员绑定,如 ?.Member + node is InterpolatedStringExpressionSyntax || // 字符串插值,如 $"Hello {name}" + node is InterpolatedStringTextSyntax || // 字符串插值文本部分 + node is InterpolationSyntax) // 插值表达式 {xxx} + { + base.Visit(node); + return; + } + + // 4. 允许:return 语句 // 不再允许,因为主程序的return语句没有存在的意义 + // if (node is ReturnStatementSyntax) + // { + // base.Visit(node); + // return; + // } + + // 5. 允许:类型转换 + if (node is CastExpressionSyntax) + { + base.Visit(node); + return; + } + + // 6. 禁止一切其他行为 + // 此时 node 可能是 BinaryExpressionSyntax (加减乘除), IfStatementSyntax (条件), WhileStatementSyntax (循环) 等 + IsValid = false; + var location = node.GetLocation(); + var lineSpan = location.GetLineSpan(); + ForbiddenReason = $"Unsupported syntax: {node.Kind()} at line {lineSpan.StartLinePosition.Line + 1}"; + } +} \ No newline at end of file From 81e10a50306e9215a3938be9216400fc8de6a261 Mon Sep 17 00:00:00 2001 From: StarInk Date: Sat, 20 Jun 2026 10:04:01 +0200 Subject: [PATCH 103/110] =?UTF-8?q?=F0=9F=A7=A9=20Refactor(Workflow):=20?= =?UTF-8?q?=E6=A0=B9=E7=9B=AE=E5=BD=95=E5=BD=92=E4=BD=8D+Services=E5=AD=90?= =?UTF-8?q?=E7=9B=AE=E5=BD=95-=E4=BB=A3=E7=A0=81=E7=BB=84=E7=BB=87?= =?UTF-8?q?=E9=87=8D=E6=9E=84=E9=98=B6=E6=AE=B5L11?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 清理根目录杂项桶,错位文件归位:新建 Services/ 子目录归集 11 个高层 Workflow 服务/Manager,根目录仅留 GlobalUsings.cs。 变更: - BlockScriptServiceImpl.cs: root -> BlockScripting/,namespace KitX.Workflow -> KitX.Workflow.BlockScripting(它是 BlockScripting 服务实现) - 新建 Services/ 并归集(namespace 统一改为 KitX.Workflow.Services): WorkflowScriptService/WorkflowManagementService/WorkflowPluginService/WorkflowStorageService/WorkflowRuntimeState/WorkflowOutput/WorkflowCase/TriggerManager/KcsFileService/StrictScriptValidator/RealPluginManager - GlobalUsings.cs: 新增 global using KitX.Workflow.Services(跨子系统消费点统一可见,匹配既有 Compilation/BlockScripting/Conversion/CFG 全局 using 模式) - 消费点同步 using:Hosting/ServiceCollectionExtensions、BuiltinFunctions/PrintFunction+PluginCallFunction+PluginCallWithTargetFunction(RealPluginManager 类型强转 / WorkflowOutput 调用) - Dashboard 子模块:BlueprintEditorViewModel 唯一一处 KitX.Workflow.WorkflowOutput 限定名 -> KitX.Workflow.Services.WorkflowOutput(已先在子模块提交 7311968,本次更新主仓库指针) 排除(按 KISS 约束): - 不动 Hosting/(ServiceLocator/DI 注册属宿主接入层,保持原位) - RealPluginManager/TriggerManager 经 Contract 接口被 Dashboard/Core 消费,无需跨项目改 验证:5项目 build 0 错误 + 全测试 PASS(A-W,33 项)。 Date: 2026-06-20 Author: StarInk --- KitX Clients/KitX Dashboard | 2 +- .../{ => BlockScripting}/BlockScriptServiceImpl.cs | 4 ++-- .../KitX.Workflow/BuiltinFunctions/PluginCallFunction.cs | 1 + .../BuiltinFunctions/PluginCallWithTargetFunction.cs | 1 + .../KitX.Workflow/BuiltinFunctions/PrintFunction.cs | 1 + KitX Clients/KitX Workflow/KitX.Workflow/GlobalUsings.cs | 5 +++++ .../KitX.Workflow/Hosting/ServiceCollectionExtensions.cs | 1 + .../KitX.Workflow/{ => Services}/KcsFileService.cs | 2 +- .../KitX.Workflow/{ => Services}/RealPluginManager.cs | 2 +- .../KitX.Workflow/{ => Services}/StrictScriptValidator.cs | 2 +- .../KitX.Workflow/{ => Services}/TriggerManager.cs | 2 +- .../KitX.Workflow/{ => Services}/WorkflowCase.cs | 2 +- .../{ => Services}/WorkflowManagementService.cs | 3 ++- .../KitX.Workflow/{ => Services}/WorkflowOutput.cs | 2 +- .../KitX.Workflow/{ => Services}/WorkflowPluginService.cs | 2 +- .../KitX.Workflow/{ => Services}/WorkflowRuntimeState.cs | 2 +- .../KitX.Workflow/{ => Services}/WorkflowScriptService.cs | 3 ++- .../KitX.Workflow/{ => Services}/WorkflowStorageService.cs | 2 +- 18 files changed, 25 insertions(+), 14 deletions(-) rename KitX Clients/KitX Workflow/KitX.Workflow/{ => BlockScripting}/BlockScriptServiceImpl.cs (99%) rename KitX Clients/KitX Workflow/KitX.Workflow/{ => Services}/KcsFileService.cs (97%) rename KitX Clients/KitX Workflow/KitX.Workflow/{ => Services}/RealPluginManager.cs (99%) rename KitX Clients/KitX Workflow/KitX.Workflow/{ => Services}/StrictScriptValidator.cs (98%) rename KitX Clients/KitX Workflow/KitX.Workflow/{ => Services}/TriggerManager.cs (99%) rename KitX Clients/KitX Workflow/KitX.Workflow/{ => Services}/WorkflowCase.cs (95%) rename KitX Clients/KitX Workflow/KitX.Workflow/{ => Services}/WorkflowManagementService.cs (99%) rename KitX Clients/KitX Workflow/KitX.Workflow/{ => Services}/WorkflowOutput.cs (97%) rename KitX Clients/KitX Workflow/KitX.Workflow/{ => Services}/WorkflowPluginService.cs (99%) rename KitX Clients/KitX Workflow/KitX.Workflow/{ => Services}/WorkflowRuntimeState.cs (97%) rename KitX Clients/KitX Workflow/KitX.Workflow/{ => Services}/WorkflowScriptService.cs (98%) rename KitX Clients/KitX Workflow/KitX.Workflow/{ => Services}/WorkflowStorageService.cs (99%) diff --git a/KitX Clients/KitX Dashboard b/KitX Clients/KitX Dashboard index 5d5fff9b..73119689 160000 --- a/KitX Clients/KitX Dashboard +++ b/KitX Clients/KitX Dashboard @@ -1 +1 @@ -Subproject commit 5d5fff9bcaf7635423cde652fca5586cb7af498b +Subproject commit 73119689286e0b0df4a8a9a4d3f29f62000e5a44 diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScriptServiceImpl.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptServiceImpl.cs similarity index 99% rename from KitX Clients/KitX Workflow/KitX.Workflow/BlockScriptServiceImpl.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptServiceImpl.cs index e1fa54c1..6a9e0b41 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScriptServiceImpl.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptServiceImpl.cs @@ -1,9 +1,9 @@ using KitX.Core.Contract.Workflow; -using KitX.Workflow.BlockScripting; using KitX.Workflow.Abstractions; +using KitX.Workflow.Services; using Serilog; -namespace KitX.Workflow; +namespace KitX.Workflow.BlockScripting; /// /// BlockScript parsing and execution service. diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PluginCallFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PluginCallFunction.cs index 2bddc07f..8c671444 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PluginCallFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PluginCallFunction.cs @@ -5,6 +5,7 @@ using KitX.Workflow.CFG; using KitX.Workflow.BlockScripting; using KitX.Workflow.Blueprint; +using KitX.Workflow.Services; using static KitX.Workflow.BlockScripting.BlockScriptWellKnown.Pins; namespace KitX.Workflow.BuiltinFunctions diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PluginCallWithTargetFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PluginCallWithTargetFunction.cs index f5861b02..511c2e37 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PluginCallWithTargetFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PluginCallWithTargetFunction.cs @@ -4,6 +4,7 @@ using KitX.Workflow.Conversion; using KitX.Workflow.CFG; using KitX.Workflow.BlockScripting; +using KitX.Workflow.Services; namespace KitX.Workflow.BuiltinFunctions { diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PrintFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PrintFunction.cs index 6a095b93..f39c3105 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PrintFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PrintFunction.cs @@ -3,6 +3,7 @@ using KitX.Workflow.Conversion; using KitX.Workflow.CFG; using KitX.Workflow.BlockScripting; +using KitX.Workflow.Services; namespace KitX.Workflow.BuiltinFunctions { diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/GlobalUsings.cs b/KitX Clients/KitX Workflow/KitX.Workflow/GlobalUsings.cs index 7ce3bc3a..195cc089 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/GlobalUsings.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/GlobalUsings.cs @@ -16,3 +16,8 @@ global using KitX.Workflow.BlockScripting; global using KitX.Workflow.Conversion; global using KitX.Workflow.CFG; +// Services holds the high-level workflow service implementations (WorkflowManagementService, +// RealPluginManager, WorkflowOutput, etc.) consumed by Hosting's DI wiring and by the +// BuiltinFunctions that call into plugin/workflow services. Global so those consumers resolve +// the concrete service types without per-file using churn. +global using KitX.Workflow.Services; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Hosting/ServiceCollectionExtensions.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Hosting/ServiceCollectionExtensions.cs index bdb111d5..daf79e9a 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Hosting/ServiceCollectionExtensions.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Hosting/ServiceCollectionExtensions.cs @@ -7,6 +7,7 @@ using KitX.Workflow.BlockScripting; using KitX.Workflow.Blueprint; using KitX.Workflow.Conversion; +using KitX.Workflow.Services; using Serilog; namespace KitX.Workflow.Hosting; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/KcsFileService.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Services/KcsFileService.cs similarity index 97% rename from KitX Clients/KitX Workflow/KitX.Workflow/KcsFileService.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Services/KcsFileService.cs index e8f35601..a9d1477d 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/KcsFileService.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Services/KcsFileService.cs @@ -2,7 +2,7 @@ using KitX.Core.Contract.Workflow; using Serilog; -namespace KitX.Workflow; +namespace KitX.Workflow.Services; /// /// KCS文件服务实现 - 仅负责KCS文件的读写 diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/RealPluginManager.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Services/RealPluginManager.cs similarity index 99% rename from KitX Clients/KitX Workflow/KitX.Workflow/RealPluginManager.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Services/RealPluginManager.cs index af131df5..05ac2431 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/RealPluginManager.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Services/RealPluginManager.cs @@ -13,7 +13,7 @@ using KitX.Shared.CSharp.WebCommand.Infos; using Serilog; -namespace KitX.Workflow; +namespace KitX.Workflow.Services; /// /// 真实的插件管理器实现,通过 WebSocket 与插件通信。 diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/StrictScriptValidator.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Services/StrictScriptValidator.cs similarity index 98% rename from KitX Clients/KitX Workflow/KitX.Workflow/StrictScriptValidator.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Services/StrictScriptValidator.cs index be033bc6..4aa77f4d 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/StrictScriptValidator.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Services/StrictScriptValidator.cs @@ -2,7 +2,7 @@ using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; -namespace KitX.Workflow; +namespace KitX.Workflow.Services; /// /// 严格的脚本验证器 - 使用 CSharpSyntaxWalker 检查允许的语法 diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/TriggerManager.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Services/TriggerManager.cs similarity index 99% rename from KitX Clients/KitX Workflow/KitX.Workflow/TriggerManager.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Services/TriggerManager.cs index 3a85c0d2..a570ea2c 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/TriggerManager.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Services/TriggerManager.cs @@ -8,7 +8,7 @@ using KitX.Workflow.Hosting; using PluginMessageReceivedEventArgs = KitX.Core.Contract.Plugin.Events.PluginMessageReceivedEventArgs; -namespace KitX.Workflow; +namespace KitX.Workflow.Services; /// /// 管理触发器路由:从插件接收触发信号,路由到匹配的工作流并执行。 diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/WorkflowCase.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Services/WorkflowCase.cs similarity index 95% rename from KitX Clients/KitX Workflow/KitX.Workflow/WorkflowCase.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Services/WorkflowCase.cs index b7bb2eda..b70bf332 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/WorkflowCase.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Services/WorkflowCase.cs @@ -1,6 +1,6 @@ using KitX.Core.Contract.Workflow; -namespace KitX.Workflow; +namespace KitX.Workflow.Services; /// /// Workflow case implementation diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/WorkflowManagementService.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Services/WorkflowManagementService.cs similarity index 99% rename from KitX Clients/KitX Workflow/KitX.Workflow/WorkflowManagementService.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Services/WorkflowManagementService.cs index 784aa0ce..7b7374bd 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/WorkflowManagementService.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Services/WorkflowManagementService.cs @@ -1,7 +1,8 @@ using KitX.Core.Contract.Workflow; +using KitX.Workflow.BlockScripting; using Serilog; -namespace KitX.Workflow; +namespace KitX.Workflow.Services; /// /// Workflow lifecycle management service. diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/WorkflowOutput.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Services/WorkflowOutput.cs similarity index 97% rename from KitX Clients/KitX Workflow/KitX.Workflow/WorkflowOutput.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Services/WorkflowOutput.cs index 7a52e097..5e852eb3 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/WorkflowOutput.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Services/WorkflowOutput.cs @@ -1,7 +1,7 @@ using System.Collections.Concurrent; using System.Text; -namespace KitX.Workflow; +namespace KitX.Workflow.Services; /// /// 工作流脚本输出辅助类 diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/WorkflowPluginService.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Services/WorkflowPluginService.cs similarity index 99% rename from KitX Clients/KitX Workflow/KitX.Workflow/WorkflowPluginService.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Services/WorkflowPluginService.cs index e8ff5ed6..6a163a96 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/WorkflowPluginService.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Services/WorkflowPluginService.cs @@ -5,7 +5,7 @@ using Microsoft.CodeAnalysis.CSharp; using Serilog; -namespace KitX.Workflow; +namespace KitX.Workflow.Services; /// /// Plugin coordination service for workflow script processing. diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/WorkflowRuntimeState.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Services/WorkflowRuntimeState.cs similarity index 97% rename from KitX Clients/KitX Workflow/KitX.Workflow/WorkflowRuntimeState.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Services/WorkflowRuntimeState.cs index 32da92a7..f95e8972 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/WorkflowRuntimeState.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Services/WorkflowRuntimeState.cs @@ -3,7 +3,7 @@ using KitX.Workflow.BlockScripting; using KitX.Shared.CSharp.Plugin; -namespace KitX.Workflow; +namespace KitX.Workflow.Services; /// /// Shared state container for workflow runtime services. diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/WorkflowScriptService.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Services/WorkflowScriptService.cs similarity index 98% rename from KitX Clients/KitX Workflow/KitX.Workflow/WorkflowScriptService.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Services/WorkflowScriptService.cs index ca4271bf..dac23101 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/WorkflowScriptService.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Services/WorkflowScriptService.cs @@ -1,9 +1,10 @@ using KitX.Core.Contract.Workflow; +using KitX.Workflow.BlockScripting; using KitX.Workflow.Hosting; using KitX.Shared.CSharp.Plugin; using Serilog; -namespace KitX.Workflow; +namespace KitX.Workflow.Services; /// /// Static factory container for the workflow service graph. diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/WorkflowStorageService.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Services/WorkflowStorageService.cs similarity index 99% rename from KitX Clients/KitX Workflow/KitX.Workflow/WorkflowStorageService.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Services/WorkflowStorageService.cs index 022234a1..0c79dba4 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/WorkflowStorageService.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Services/WorkflowStorageService.cs @@ -3,7 +3,7 @@ using KitX.Workflow.Hosting; using Serilog; -namespace KitX.Workflow; +namespace KitX.Workflow.Services; /// /// Workflow storage service implementation - manages workflow file persistence From 7a88c626531fc8c696d973418d1485f48e3122f0 Mon Sep 17 00:00:00 2001 From: StarInk Date: Sat, 20 Jun 2026 10:12:10 +0200 Subject: [PATCH 104/110] =?UTF-8?q?=F0=9F=A7=A9=20Refactor(Workflow):=20?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E9=A1=B9=E7=9B=AE=E5=BD=92=E4=BD=8DKitX.Work?= =?UTF-8?q?flow.Test-=E4=BB=A3=E7=A0=81=E7=BB=84=E7=BB=87=E9=87=8D?= =?UTF-8?q?=E6=9E=84=E9=98=B6=E6=AE=B5L12?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 KitX.Core.BluePrint.Test 项目从 KitX Clients/KitX Core/ 移动到 KitX Clients/KitX Workflow/ 并重命名为 KitX.Workflow.Test,归属其真正测试的 KitX.Workflow 程序集。 变更: - 目录 git mv:KitX Clients/KitX Core/KitX.Core.BluePrint.Test -> KitX Clients/KitX Workflow/KitX.Workflow.Test - csproj 重命名:KitX.Core.BluePrint.Test.csproj -> KitX.Workflow.Test.csproj - csproj 内容:RootNamespace KitX.Core.BluePrint.Test -> KitX.Workflow.Test;ProjectReference 路径适配新位置(..\..\KitX Core\KitX.Core\...);新增直接引用 ..\KitX.Workflow\KitX.Workflow.csproj(原本经 KitX.Core 间接引用,现为 Workflow 测试应显式直引) - 源文件 namespace:Program.cs / KcsCompileTest.cs 的 KitX.Core.BluePrint.Test -> KitX.Workflow.Test - Program.cs:2 处 Contract.Workflow.Blueprint 半限定名 -> KitX.Core.Contract.Workflow.Blueprint 全限定(原项目命名空间 KitX.Core.BluePrint.Test 父级 KitX.Core 提供 Contract.Workflow 解析上下文,迁移后失去该上下文,全限定以消除歧义) - KitX.Workflow.csproj / KitX.Core.csproj:InternalsVisibleTo 由 KitX.Core.BluePrint.Test -> KitX.Workflow.Test - IBlockScriptParser.cs XML doc 注释同步:KitX.Core.BluePrint.Test -> KitX.Workflow.Test - KitX.sln:项目名+路径更新,解决方案文件夹归属从 KitX Core 改为 KitX Workflow Data/ 目录(运行时生成的 CompiledScripts)经 gitignore 忽略,随测试运行在新位置自动重建。 验证:Workflow.Test build 0 错误 + 全测试 PASS(A-W,33 项)+ Dashboard build 0 错误。 Date: 2026-06-20 Author: StarInk --- KitX Clients/KitX Core/KitX.Core/KitX.Core.csproj | 2 +- .../KitX.Workflow.Test}/KcsCompileTest.cs | 2 +- .../KitX.Workflow.Test/KitX.Workflow.Test.csproj} | 5 +++-- .../KitX.Workflow.Test}/Program.cs | 6 +++--- .../KitX.Workflow/Abstractions/IBlockScriptParser.cs | 2 +- .../KitX Workflow/KitX.Workflow/KitX.Workflow.csproj | 2 +- KitX.sln | 4 ++-- 7 files changed, 12 insertions(+), 11 deletions(-) rename KitX Clients/{KitX Core/KitX.Core.BluePrint.Test => KitX Workflow/KitX.Workflow.Test}/KcsCompileTest.cs (99%) rename KitX Clients/{KitX Core/KitX.Core.BluePrint.Test/KitX.Core.BluePrint.Test.csproj => KitX Workflow/KitX.Workflow.Test/KitX.Workflow.Test.csproj} (71%) rename KitX Clients/{KitX Core/KitX.Core.BluePrint.Test => KitX Workflow/KitX.Workflow.Test}/Program.cs (99%) diff --git a/KitX Clients/KitX Core/KitX.Core/KitX.Core.csproj b/KitX Clients/KitX Core/KitX.Core/KitX.Core.csproj index f35799ac..424ded27 100644 --- a/KitX Clients/KitX Core/KitX.Core/KitX.Core.csproj +++ b/KitX Clients/KitX Core/KitX.Core/KitX.Core.csproj @@ -19,7 +19,7 @@ <_Parameter1>KitX.Core.DI.Tests - <_Parameter1>KitX.Core.BluePrint.Test + <_Parameter1>KitX.Workflow.Test diff --git a/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/KcsCompileTest.cs b/KitX Clients/KitX Workflow/KitX.Workflow.Test/KcsCompileTest.cs similarity index 99% rename from KitX Clients/KitX Core/KitX.Core.BluePrint.Test/KcsCompileTest.cs rename to KitX Clients/KitX Workflow/KitX.Workflow.Test/KcsCompileTest.cs index 4509611b..5bbf1c1c 100644 --- a/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/KcsCompileTest.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow.Test/KcsCompileTest.cs @@ -15,7 +15,7 @@ using KitX.Workflow.CFG; using KitX.Workflow.Conversion; -namespace KitX.Core.BluePrint.Test; +namespace KitX.Workflow.Test; /// /// Compiles a .kcs workflow file end-to-end (parse + BS→CFG→CS→assembly) and reports diff --git a/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/KitX.Core.BluePrint.Test.csproj b/KitX Clients/KitX Workflow/KitX.Workflow.Test/KitX.Workflow.Test.csproj similarity index 71% rename from KitX Clients/KitX Core/KitX.Core.BluePrint.Test/KitX.Core.BluePrint.Test.csproj rename to KitX Clients/KitX Workflow/KitX.Workflow.Test/KitX.Workflow.Test.csproj index 4093a825..a4db22f9 100644 --- a/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/KitX.Core.BluePrint.Test.csproj +++ b/KitX Clients/KitX Workflow/KitX.Workflow.Test/KitX.Workflow.Test.csproj @@ -5,12 +5,13 @@ net10.0 enable enable - KitX.Core.BluePrint.Test + KitX.Workflow.Test - + + diff --git a/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs b/KitX Clients/KitX Workflow/KitX.Workflow.Test/Program.cs similarity index 99% rename from KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs rename to KitX Clients/KitX Workflow/KitX.Workflow.Test/Program.cs index a78aaae6..c1132d79 100644 --- a/KitX Clients/KitX Core/KitX.Core.BluePrint.Test/Program.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow.Test/Program.cs @@ -15,7 +15,7 @@ using KitX.Workflow.Abstractions.Models.Results; using KitX.Workflow.Conversion; -namespace KitX.Core.BluePrint.Test; +namespace KitX.Workflow.Test; public partial class Program { @@ -891,7 +891,7 @@ private static void RunManualBlueprintTest( { try { - var bp = new Contract.Workflow.Blueprint + var bp = new KitX.Core.Contract.Workflow.Blueprint { Name = "ManualTest" }; @@ -987,7 +987,7 @@ private static void RunNestedControlFlowTopologyTest( { try { - var bp = new Contract.Workflow.Blueprint { Name = "NestedControlFlowTopology" }; + var bp = new KitX.Core.Contract.Workflow.Blueprint { Name = "NestedControlFlowTopology" }; // Entry → Branch(cond, TrueArm, FalseArm); TrueArm → Loop(cond, LoopBody, LoopEnd) var entry = new EntryNode(); diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/IBlockScriptParser.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/IBlockScriptParser.cs index 787df063..751bb74a 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/IBlockScriptParser.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/IBlockScriptParser.cs @@ -8,7 +8,7 @@ namespace KitX.Workflow.Abstractions; /// Block script parser interface — parses C# scripts with block attributes. /// /// Internal to the workflow pipeline (only BlockScriptExecutor / DI use it); moved out of the -/// public Contract surface. Consumed cross-assembly by the KitX.Core.BluePrint.Test project. +/// public Contract surface. Consumed cross-assembly by the KitX.Workflow.Test project. /// public interface IBlockScriptParser { diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/KitX.Workflow.csproj b/KitX Clients/KitX Workflow/KitX.Workflow/KitX.Workflow.csproj index 889d6be8..1f28d416 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/KitX.Workflow.csproj +++ b/KitX Clients/KitX Workflow/KitX.Workflow/KitX.Workflow.csproj @@ -19,7 +19,7 @@ <_Parameter1>KitX.Core.DI.Tests - <_Parameter1>KitX.Core.BluePrint.Test + <_Parameter1>KitX.Workflow.Test diff --git a/KitX.sln b/KitX.sln index 76cf9783..fec90dc1 100644 --- a/KitX.sln +++ b/KitX.sln @@ -157,7 +157,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KitX.Core", "KitX Clients\K EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KitX.Core.DI.Tests", "KitX Clients\KitX Core\KitX.Core.DI.Tests\KitX.Core.DI.Tests.csproj", "{9D8CEB24-748C-0392-629C-45342937C32A}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KitX.Core.BluePrint.Test", "KitX Clients\KitX Core\KitX.Core.BluePrint.Test\KitX.Core.BluePrint.Test.csproj", "{3C461B17-B9A4-97E1-9A62-79BC972F7A13}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KitX.Workflow.Test", "KitX Clients\KitX Workflow\KitX.Workflow.Test\KitX.Workflow.Test.csproj", "{3C461B17-B9A4-97E1-9A62-79BC972F7A13}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "KitX Workflow", "KitX Workflow", "{5B220184-3539-443A-A12B-4381F4817D13}" EndProject @@ -1053,7 +1053,7 @@ Global {C4C2D1CC-294B-4C93-8A64-34D5212AD74E} = {673CF32C-65BF-4EB3-83D3-47FEC77B47A0} {641562BA-D2B5-4799-8712-11C23912A79B} = {C4C2D1CC-294B-4C93-8A64-34D5212AD74E} {9D8CEB24-748C-0392-629C-45342937C32A} = {C4C2D1CC-294B-4C93-8A64-34D5212AD74E} - {3C461B17-B9A4-97E1-9A62-79BC972F7A13} = {C4C2D1CC-294B-4C93-8A64-34D5212AD74E} + {3C461B17-B9A4-97E1-9A62-79BC972F7A13} = {5B220184-3539-443A-A12B-4381F4817D13} {5B220184-3539-443A-A12B-4381F4817D13} = {673CF32C-65BF-4EB3-83D3-47FEC77B47A0} {E4438FD7-74FB-417C-B4F1-FC5C6040F5FC} = {5B220184-3539-443A-A12B-4381F4817D13} {F2A135DA-A0B5-317D-1E23-DC7BBACF02C9} = {F021B1E7-44AC-469B-AB4D-36541902C694} From e641b08e951f1ebad99a9124912a53146b8032ee Mon Sep 17 00:00:00 2001 From: StarInk Date: Sat, 20 Jun 2026 10:46:23 +0200 Subject: [PATCH 105/110] =?UTF-8?q?=F0=9F=A7=A9=20Refactor(Workflow):=20?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E8=A7=A6=E5=8F=91=E5=99=A8=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E5=A4=84=E7=90=86=E9=80=BB=E8=BE=91=EF=BC=8C=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E5=86=97=E4=BD=99=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- KitX Clients/KitX Dashboard | 2 +- .../KitX.Workflow.Test/KcsCompileTest.cs | 2 +- .../BlockScripting/BlockScriptServiceImpl.cs | 58 +++++-------------- .../CreateWorkflowFunction.cs | 1 - .../BuiltinFunctions/ListWorkflowsFunction.cs | 2 +- .../Hosting/ServiceCollectionExtensions.cs | 9 ++- .../KitX.Workflow/Services/WorkflowCase.cs | 2 - .../Services/WorkflowPluginService.cs | 27 --------- .../Services/WorkflowRuntimeState.cs | 10 ---- .../Services/WorkflowStorageService.cs | 12 +--- KitX Standard | 2 +- 11 files changed, 26 insertions(+), 101 deletions(-) diff --git a/KitX Clients/KitX Dashboard b/KitX Clients/KitX Dashboard index 73119689..419bb97f 160000 --- a/KitX Clients/KitX Dashboard +++ b/KitX Clients/KitX Dashboard @@ -1 +1 @@ -Subproject commit 73119689286e0b0df4a8a9a4d3f29f62000e5a44 +Subproject commit 419bb97f48b0d4311a3da48ba2f27f9ead5d25c3 diff --git a/KitX Clients/KitX Workflow/KitX.Workflow.Test/KcsCompileTest.cs b/KitX Clients/KitX Workflow/KitX.Workflow.Test/KcsCompileTest.cs index 5bbf1c1c..6d55085e 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow.Test/KcsCompileTest.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow.Test/KcsCompileTest.cs @@ -60,7 +60,7 @@ public static void RunKcsCompileTest(string kcsPath, bool withRoundTrip = false) Console.WriteLine($"Name: {kcs.Name}"); Console.WriteLine($"Id: {kcs.Id}"); - Console.WriteLine($"Trigger: {kcs.TriggerType}"); + Console.WriteLine($"Trigger: {kcs.TriggerConfig?.TriggerType ?? "Manual"}"); Console.WriteLine($"UseBlockMode: {kcs.UseBlockMode}"); Console.WriteLine($"Helpers: {(kcs.HelperFunctions?.Count ?? 0)}"); Console.WriteLine(); diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptServiceImpl.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptServiceImpl.cs index 6a9e0b41..39c433bf 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptServiceImpl.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptServiceImpl.cs @@ -16,40 +16,26 @@ namespace KitX.Workflow.BlockScripting; internal class BlockScriptServiceImpl : IBlockScriptService { private readonly WorkflowRuntimeState _state; - private RealPluginManager? _realPluginManager; + private readonly BlockScriptExecutor _executor; /// /// Initializes a new instance of BlockScriptServiceImpl. /// /// Shared runtime state. - /// RealPluginManager instance from DI container (optional). - internal BlockScriptServiceImpl(WorkflowRuntimeState state, RealPluginManager? realPluginManager = null) + /// + /// The DI-registered, plugin-manager-wired singleton. + /// Must be the same instance registered in AddKitXWorkflow (where its + /// SetPluginManager is called with the ), so that + /// G.PluginCall(...) dispatches to live plugins during execution. Injecting it here + /// (instead of lazily constructing a bare new BlockScriptExecutor()) eliminates the + /// prior dual-instance bug where the trigger execution path ran on an unwired executor. + /// + internal BlockScriptServiceImpl(WorkflowRuntimeState state, IBlockScriptExecutor executor) { _state = state; - _realPluginManager = realPluginManager; - TrySetPluginManager(); - } - - /// - /// Sets the RealPluginManager after initialization. - /// This is needed when RealPluginManager is resolved after BlockScriptServiceImpl is created. - /// - internal void SetRealPluginManager(RealPluginManager? realPluginManager) - { - _realPluginManager = realPluginManager; - TrySetPluginManager(); - } - - /// - /// Try to set the plugin manager on the BlockScriptExecutor if conditions are met. - /// - private void TrySetPluginManager() - { - if (_state.BlockScriptExecutor != null && _state.IsParserInitialized && _realPluginManager != null) - { - Log.Information("[BlockScriptServiceImpl] Setting RealPluginManager. HashCode: {HashCode}", _realPluginManager.GetHashCode()); - _state.BlockScriptExecutor.SetPluginManager(_realPluginManager); - } + // The contract guarantees a wired executor; cast once to the concrete type the + // pipeline (ExecuteAsync / CompileForPersistence / PreloadFromDisk / Validate) needs. + _executor = (BlockScriptExecutor)executor; } /// @@ -60,23 +46,9 @@ private void TrySetPluginManager() BuiltinFunctionRegistry.Discover(typeof(BuiltinFunctionRegistry).Assembly)); /// - /// Gets the BlockScript executor, creating it if necessary. - /// The executor is initialized with the plugin manager if the parser is initialized. + /// Gets the DI-injected BlockScript executor (single, plugin-manager-wired instance). /// - private BlockScriptExecutor BlockScriptExecutor - { - get - { - if (_state.BlockScriptExecutor == null) - { - Log.Information("[BlockScriptServiceImpl] Creating new BlockScriptExecutor, IsParserInitialized = {_IsParserInitialized}", - _state.IsParserInitialized); - _state.BlockScriptExecutor = new BlockScriptExecutor(); - TrySetPluginManager(); - } - return _state.BlockScriptExecutor; - } - } + private BlockScriptExecutor BlockScriptExecutor => _executor; /// public BlockScriptParseResult ParseBlockScript(string sourceCode) diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/CreateWorkflowFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/CreateWorkflowFunction.cs index 19a46cf3..c14da422 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/CreateWorkflowFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/CreateWorkflowFunction.cs @@ -75,7 +75,6 @@ public string CreateWorkflow(string name, string blockScriptSource) LastModifiedTime = DateTime.UtcNow, UseBlockMode = true, BlockScriptSource = blockScriptSource, - TriggerType = "Manual", Author = "AI Assistant", Description = "Workflow generated by KitX AI Assistant" }; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ListWorkflowsFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ListWorkflowsFunction.cs index 2150dfbd..c035f119 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ListWorkflowsFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ListWorkflowsFunction.cs @@ -67,7 +67,7 @@ public string ListWorkflows() w.Id, w.Name, w.Description, - w.TriggerType + TriggerType = w.TriggerConfig?.TriggerType ?? "Manual" }); return JsonSerializer.Serialize(info); } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Hosting/ServiceCollectionExtensions.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Hosting/ServiceCollectionExtensions.cs index daf79e9a..832a727a 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Hosting/ServiceCollectionExtensions.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Hosting/ServiceCollectionExtensions.cs @@ -32,14 +32,17 @@ public static IServiceCollection AddKitXWorkflow(this IServiceCollection service { Log.Information("[AddKitXWorkflow] Registering workflow services..."); - // IBlockScriptService is created via factory to inject RealPluginManager from DI. + // IBlockScriptService is created via factory. It receives the same DI-registered, + // plugin-manager-wired IBlockScriptExecutor singleton (registered below) so the trigger + // execution path runs plugins through the wired executor, not a bare new instance. // WorkflowScriptService facade is kept as the backward-compat singleton graph. services.AddSingleton(provider => { var state = WorkflowScriptService.RuntimeState; var rpm = provider.GetRequiredService(); - var service = new BlockScriptServiceImpl(state, rpm); - Log.Information("[DI] IBlockScriptService created with RealPluginManager. HashCode: {HashCode}", rpm.GetHashCode()); + var executor = provider.GetRequiredService(); + var service = new BlockScriptServiceImpl(state, executor); + Log.Information("[DI] IBlockScriptService created with RealPluginManager + wired executor. RPM HashCode: {HashCode}", rpm.GetHashCode()); return service; }); // IBlockScriptPipelineService was removed (zero interface-type consumers; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Services/WorkflowCase.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Services/WorkflowCase.cs index b70bf332..420880b3 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Services/WorkflowCase.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Services/WorkflowCase.cs @@ -27,7 +27,5 @@ public class WorkflowCase : IWorkflowCase public DateTime LastModifiedTime { get; set; } = DateTime.UtcNow; - public string TriggerType { get; set; } = "Manual"; - public TriggerConfig? TriggerConfig { get; set; } } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Services/WorkflowPluginService.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Services/WorkflowPluginService.cs index 6a163a96..9867cb51 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Services/WorkflowPluginService.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Services/WorkflowPluginService.cs @@ -1,6 +1,5 @@ using KitX.Core.Contract.Workflow; using KitX.Core.Contract.Plugin; -using KitX.Workflow.Hosting; using KitX.Shared.CSharp.Plugin; using Microsoft.CodeAnalysis.CSharp; using Serilog; @@ -24,32 +23,6 @@ internal WorkflowPluginService(WorkflowRuntimeState state) _state = state; } - /// - public void InitializePluginManager() - { - if (_state.IsParserInitialized) return; - - try - { - if (!ServiceLocator.IsInitialized) - { - Log.Error("[WorkflowPluginService] Cannot initialize plugin manager: ServiceLocator not initialized"); - return; - } - - var pluginServer = ServiceLocator.GetRequiredService(); - var realPluginManager = new RealPluginManager(pluginServer); - - _state.IsParserInitialized = true; - Log.Information("[WorkflowPluginService] Real plugin manager initialized"); - } - catch (Exception ex) - { - Log.Error($"[WorkflowPluginService] Failed to initialize real plugin manager: {ex.Message}"); - _state.IsParserInitialized = true; - } - } - /// public void UpdateAvailablePlugins(List plugins) { diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Services/WorkflowRuntimeState.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Services/WorkflowRuntimeState.cs index f95e8972..a9dc6ebf 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Services/WorkflowRuntimeState.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Services/WorkflowRuntimeState.cs @@ -22,11 +22,6 @@ internal class WorkflowRuntimeState /// internal CSharpScriptEngine? Engine; - /// - /// Whether the plugin manager has been initialized. - /// - internal bool IsParserInitialized; - /// /// Available plugins for workflow execution. /// @@ -36,9 +31,4 @@ internal class WorkflowRuntimeState /// Block script parser instance. /// internal BlockScriptParser? BlockScriptParser; - - /// - /// Block script executor instance. - /// - internal BlockScriptExecutor? BlockScriptExecutor; } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Services/WorkflowStorageService.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Services/WorkflowStorageService.cs index 0c79dba4..4efe3f48 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Services/WorkflowStorageService.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Services/WorkflowStorageService.cs @@ -60,7 +60,6 @@ public async Task CreateWorkflowAsync(string name, string? descri Author = string.Empty, CreatedTime = now, LastModifiedTime = now, - TriggerType = "Manual", UseBlockMode = true, BlockScriptSource = GetDefaultBlockScriptTemplate(), MainProgram = string.Empty, @@ -84,7 +83,6 @@ public async Task CreateWorkflowAsync(string name, string? descri ScriptPath = filePath, CreatedTime = now, LastModifiedTime = now, - TriggerType = "Manual", }; } @@ -169,11 +167,6 @@ public async Task> DiscoverWorkflowsAsync() ? File.GetCreationTimeUtc(file) : kcs.CreatedTime; - // Prefer TriggerConfig (structured) over legacy TriggerType (string) - var triggerConfig = kcs.TriggerConfig; - var triggerType = triggerConfig?.TriggerType - ?? (string.IsNullOrEmpty(kcs.TriggerType) ? "Manual" : kcs.TriggerType); - results.Add(new WorkflowCase { Id = id, @@ -184,8 +177,7 @@ public async Task> DiscoverWorkflowsAsync() ScriptPath = file, CreatedTime = createdTime, LastModifiedTime = kcs.LastModifiedTime == default ? File.GetLastWriteTimeUtc(file) : kcs.LastModifiedTime, - TriggerType = triggerType, - TriggerConfig = triggerConfig, + TriggerConfig = kcs.TriggerConfig, }); } catch (Exception ex) @@ -298,8 +290,6 @@ private void EnsureDirectoryExists() result.Description = desc.GetString() ?? string.Empty; if (root.TryGetProperty(nameof(KcsFileFormat.Author), out var author)) result.Author = author.GetString() ?? string.Empty; - if (root.TryGetProperty(nameof(KcsFileFormat.TriggerType), out var triggerType)) - result.TriggerType = triggerType.GetString() ?? "Manual"; if (root.TryGetProperty(nameof(KcsFileFormat.UseBlockMode), out var useBlockMode)) result.UseBlockMode = useBlockMode.GetBoolean(); if (root.TryGetProperty(nameof(KcsFileFormat.BlockScriptSource), out var bsSource)) diff --git a/KitX Standard b/KitX Standard index da48fc4c..2b827022 160000 --- a/KitX Standard +++ b/KitX Standard @@ -1 +1 @@ -Subproject commit da48fc4cb063dc99760b90cbe590b3f6a148016a +Subproject commit 2b8270229dcf690c0d24aa40943c156026f89431 From 28bdb1f9d576685deb27556558802929a7b7ea72 Mon Sep 17 00:00:00 2001 From: StarInk Date: Sat, 20 Jun 2026 13:13:30 +0200 Subject: [PATCH 106/110] =?UTF-8?q?=F0=9F=A7=A9=20Refactor(Workflow):=20?= =?UTF-8?q?=E6=AD=BB=E4=BB=A3=E7=A0=81=E6=B8=85=E7=90=86+BlockScripting?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=E5=BD=92=E4=BD=8D-=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E7=BB=84=E7=BB=87=E9=87=8D=E6=9E=84=E9=98=B6=E6=AE=B5L13?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 - 直接死代码删除: - 删除整文件: StrictScriptValidator/VariableDeclarationStatement/KcsFileService/ConstantValueRewriter/HelperFunctionCodeGenerator - 删除死字段: ConversionContext(7)+PipelineContext(2)+PubVarAssignment.SubAssignments+ConditionInfo.Fingerprint - 删除死方法: BlockScriptServiceImpl异步重载/CSCompiler.ClearCache/CSEmitContext.NullLiteral/ScriptCompilationBackend单参重载/ControlFlowGraph.GetBlock+NextPubVarName/NodeExportHelper.ResolveInputValue/ServiceLocator.GetService/WorkflowOutput.HasOutput Phase 2 - BlockScriptWellKnown范式收敛: - ControlFlowMapping内联5个Functions常量+删除死using static导入 - 删除整个Functions内部类(9常量)+4个死Pins(True/False/LoopBody/LoopEnd) - 注册表BuiltinFunctionRegistry已完全涵盖Functions语义,消除半完成迁移 Phase 3 - 接口契约瘦身(配合submodule KitX Standard 8276213): - 删除11个无消费者的接口方法+整个IKcsFileService接口 - 级联删除孤儿ConstantValueRewriter/HelperFunctionCodeGenerator - 同步删除DI注册IRealPluginManagerBridge->IKcsFileService Phase 4 - 文件迁移(git mv, 保留namespace避免50+文件using抖动): - IBuiltinFunctionDefinition.cs -> Abstractions/(与其它接口并列) - BlockScriptWellKnown.cs -> Abstractions/(全局常量归位) - BlockScriptServiceImpl.cs -> Services/(与其它服务实现并列) 清理: KitX.sln移除悬空SyncCodes项目条目(磁盘上不存在该csproj) 验证: Workflow/Core.Contract/Test/Dashboard四个项目均0错误构建通过 经refanalyzer工具+独立Agent交叉验证(扩展方法盲点已人工纠正RoslynExtensions误报保留) BREAKING CHANGE: 公共契约API表面缩减(详见submodule 8276213), 外部插件若依赖被删成员需迁移 Date: 2026-06-20 Author: StarInk --- .../BlockScriptWellKnown.cs | 18 --- .../IBuiltinFunctionDefinition.cs | 0 .../VariableDeclarationStatement.cs | 12 -- .../HelperFunctionCodeGenerator.cs | 82 ------------- .../Blueprint/NodeExportHelper.cs | 20 ---- .../KitX.Workflow/CFG/ControlFlowGraph.cs | 12 -- .../KitX.Workflow/Compilation/CSCompiler.cs | 10 -- .../Compilation/CSEmitContext.cs | 4 - .../Compilation/ConstantValueRewriter.cs | 109 ------------------ .../Compilation/ScriptCompilationBackend.cs | 8 -- .../Conversion/BS2CFGConverter.cs | 1 - .../Conversion/ControlFlowMapping.cs | 12 +- .../Conversion/ConversionContext.cs | 8 -- .../Conversion/PipelineContext.cs | 13 --- .../Hosting/ServiceCollectionExtensions.cs | 3 - .../KitX.Workflow/Hosting/ServiceLocator.cs | 6 - .../BlockScriptServiceImpl.cs | 24 ---- .../KitX.Workflow/Services/KcsFileService.cs | 53 --------- .../Services/StrictScriptValidator.cs | 79 ------------- .../Services/WorkflowManagementService.cs | 25 ---- .../KitX.Workflow/Services/WorkflowOutput.cs | 5 - .../Services/WorkflowPluginService.cs | 61 ---------- .../Services/WorkflowStorageService.cs | 42 ------- KitX Standard | 2 +- KitX.sln | 23 ---- 25 files changed, 6 insertions(+), 626 deletions(-) rename KitX Clients/KitX Workflow/KitX.Workflow/{BlockScripting => Abstractions}/BlockScriptWellKnown.cs (62%) rename KitX Clients/KitX Workflow/KitX.Workflow/{BlockScripting => Abstractions}/IBuiltinFunctionDefinition.cs (100%) delete mode 100644 KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/Statements/VariableDeclarationStatement.cs delete mode 100644 KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/HelperFunctionCodeGenerator.cs delete mode 100644 KitX Clients/KitX Workflow/KitX.Workflow/Compilation/ConstantValueRewriter.cs rename KitX Clients/KitX Workflow/KitX.Workflow/{BlockScripting => Services}/BlockScriptServiceImpl.cs (89%) delete mode 100644 KitX Clients/KitX Workflow/KitX.Workflow/Services/KcsFileService.cs delete mode 100644 KitX Clients/KitX Workflow/KitX.Workflow/Services/StrictScriptValidator.cs diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptWellKnown.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/BlockScriptWellKnown.cs similarity index 62% rename from KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptWellKnown.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/BlockScriptWellKnown.cs index 9d00b79d..d499f8ad 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptWellKnown.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/BlockScriptWellKnown.cs @@ -27,25 +27,7 @@ public static class Pins public const string Condition = "Condition"; public const string Value = "Value"; public const string Return = "Return"; - public const string True = "True"; - public const string False = "False"; - public const string LoopBody = "LoopBody"; - public const string LoopEnd = "LoopEnd"; public const string Default = "Default"; public const string Selector = "Selector"; } - - /// Built-in flow control and action function names - public static class Functions - { - public const string Branch = "Branch"; - public const string Loop = "Loop"; - public const string Switch = "Switch"; - public const string ToLoopCond = "ToLoopCond"; - public const string Break = "Break"; - public const string Print = "Print"; - public const string Pause = "Pause"; - public const string Get = "Get"; - public const string Set = "Set"; - } } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/IBuiltinFunctionDefinition.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/IBuiltinFunctionDefinition.cs similarity index 100% rename from KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/IBuiltinFunctionDefinition.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/IBuiltinFunctionDefinition.cs diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/Statements/VariableDeclarationStatement.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/Statements/VariableDeclarationStatement.cs deleted file mode 100644 index 7e9eecb7..00000000 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/Statements/VariableDeclarationStatement.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace KitX.Workflow.Abstractions.Models.Statements; - -/// -/// Variable declaration statement -/// -public class VariableDeclarationStatement : BlockStatement -{ - /// - /// The variable declaration - /// - public VariableDeclaration Declaration { get; set; } = new(); -} \ No newline at end of file diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/HelperFunctionCodeGenerator.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/HelperFunctionCodeGenerator.cs deleted file mode 100644 index e98578e5..00000000 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/HelperFunctionCodeGenerator.cs +++ /dev/null @@ -1,82 +0,0 @@ -using System.Text; -using KitX.Core.Contract.Workflow; - -namespace KitX.Workflow.BlockScripting; - -/// -/// Generates C# source code from HelperFunction definitions. -/// Shared utility used by both WorkflowScriptService (KCS mode) and BlockScriptExecutor. -/// -public static class HelperFunctionCodeGenerator -{ - /// - /// Generates C# method declarations from a list of helper functions. - /// - /// The helper functions to generate code for - /// Whether to add 'static' modifier to method signatures - /// Generated C# source code, or empty string if no functions - public static string GenerateCode(List? helperFunctions, bool useStaticModifier = false) - { - if (helperFunctions == null || helperFunctions.Count == 0) - return string.Empty; - - var code = new StringBuilder(); - - foreach (var func in helperFunctions) - { - // Generate method signature - if (useStaticModifier) - code.Append("static "); - - code.Append(func.ReturnType); - code.Append(" "); - code.Append(func.Name); - code.Append("("); - - // Add parameters - for (int i = 0; i < func.Parameters.Count; i++) - { - if (i > 0) code.Append(", "); - code.Append(func.Parameters[i].Type); - code.Append(" "); - code.Append(func.Parameters[i].Name); - } - - code.AppendLine(")"); - code.AppendLine("{"); - - // Add function body - if (!string.IsNullOrWhiteSpace(func.Code)) - { - foreach (var line in func.Code.Split('\n')) - { - code.AppendLine(" " + line); - } - } - - code.AppendLine("}"); - code.AppendLine(); - } - - return code.ToString(); - } - - /// - /// Generates helper function code and appends the main program code after it. - /// Used by KCS mode where helpers are merged with the main program as static methods. - /// - /// The main program code - /// The helper functions to prepend - /// Combined source code - public static string MergeWithMainProgram(string mainCode, List helperFunctions) - { - var combined = new StringBuilder(); - - combined.Append(GenerateCode(helperFunctions, useStaticModifier: true)); - - combined.AppendLine("// --- Main Program ---"); - combined.AppendLine(mainCode); - - return combined.ToString(); - } -} diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Blueprint/NodeExportHelper.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Blueprint/NodeExportHelper.cs index 06b50d6c..41f3719c 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Blueprint/NodeExportHelper.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Blueprint/NodeExportHelper.cs @@ -59,26 +59,6 @@ public string GetInputArgs(BlueprintNode node) public bool IsOutputConsumed(BlueprintNode node, string pinName) => _currentCtx?.ConsumedOutputs.Contains((node.Id, pinName)) == true; - /// - /// Resolves an input pin's value using the pre-built InputDataMap (for topology path). - /// - public string ResolveInputValue(BlueprintNode node, string pinName, ConversionContext ctx) - { - var pin = node.InputPins.FirstOrDefault(p => p.Name == pinName); - if (pin == null) return string.Empty; - - if (ctx.InputDataMap.TryGetValue((node.Id, pin.Name), out var info)) - { - if (info.SourceNode is ConstNode constNode) - return constNode.ConstName; - if (!string.IsNullOrEmpty(info.PubVarName)) - return info.PubVarName; - } - - var defaultValue = pin.DefaultValue ?? string.Empty; - return FormatLiteralValue(defaultValue, ctx); - } - /// /// Finds the PubVar name assigned to a node's output pin. /// diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/CFG/ControlFlowGraph.cs b/KitX Clients/KitX Workflow/KitX.Workflow/CFG/ControlFlowGraph.cs index 3e40e25a..de84b2d1 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/CFG/ControlFlowGraph.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/CFG/ControlFlowGraph.cs @@ -48,18 +48,6 @@ public class ControlFlowGraph public BlueprintDebugContext? DebugContext { get; set; } - /// - /// Gets a block by name. Returns null if not found. - /// - public CFGBlock? GetBlock(string name) - => Blocks.FirstOrDefault(b => b.Name == name); - - /// - /// Generates the next unique PubVar name. - /// - public string NextPubVarName() - => $"vaaa{PubVarCounter++:D4}"; - /// /// Dumps the CFG as a human-readable string for diagnostics. /// diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Compilation/CSCompiler.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Compilation/CSCompiler.cs index ff1c2e30..6c955951 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Compilation/CSCompiler.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Compilation/CSCompiler.cs @@ -198,16 +198,6 @@ public CSCompiler() } } - /// - /// Clears the compilation cache and unloads all cached assemblies. - /// - public void ClearCache() - { - foreach (var entry in _cache.Values) - entry.Unload(); - _cache.Clear(); - } - /// /// Preloads all persisted compiled scripts for a given workflow from disk. /// diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Compilation/CSEmitContext.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Compilation/CSEmitContext.cs index e9ed1222..6dcaed30 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Compilation/CSEmitContext.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Compilation/CSEmitContext.cs @@ -32,10 +32,6 @@ public CSEmitContext(Dictionary pubVarTypes, Dictionary LiteralExpression(SyntaxKind.StringLiteralExpression, SyntaxFactory.Literal(value)); - /// A null literal expression. - public ExpressionSyntax NullLiteral() - => LiteralExpression(SyntaxKind.NullLiteralExpression); - /// Resolves a formatted argument string into a Roslyn expression. public ExpressionSyntax ResolveArgument(string arg) => CFG2CSConverter.ResolveArgumentExpression(arg, PubVarTypes); diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Compilation/ConstantValueRewriter.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Compilation/ConstantValueRewriter.cs deleted file mode 100644 index 2bbc33ea..00000000 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Compilation/ConstantValueRewriter.cs +++ /dev/null @@ -1,109 +0,0 @@ -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; - -namespace KitX.Workflow.Compilation; - -/// -/// Rewriter for injecting constant values into variable declarations using CSharpSyntaxRewriter -/// -/// -/// This approach is more reliable than regex-based matching as it understands the actual -/// syntax structure and won't accidentally match content in comments or strings. -/// -internal class ConstantValueRewriter : CSharpSyntaxRewriter -{ - private readonly Dictionary _constantValues; - - public ConstantValueRewriter(Dictionary constantValues) - { - _constantValues = constantValues; - } - - /// - /// Visits variable declarators to replace their initializer values - /// - public override SyntaxNode? VisitVariableDeclarator(VariableDeclaratorSyntax node) - { - // Check if this variable declarator has an initializer and matches a constant name - if (node.Initializer != null && _constantValues.TryGetValue(node.Identifier.Text, out var newValue)) - { - var newInitializer = CreateNewInitializer(node.Initializer, newValue); - if (newInitializer != null) - { - return node.WithInitializer(newInitializer); - } - } - - return base.VisitVariableDeclarator(node); - } - - /// - /// Creates a new EqualsValueClauseSyntax with the specified value - /// - private EqualsValueClauseSyntax? CreateNewInitializer(EqualsValueClauseSyntax oldInitializer, object? value) - { - ExpressionSyntax? newExpression = null; - - if (value == null) - { - newExpression = SyntaxFactory.LiteralExpression(SyntaxKind.NullLiteralExpression); - } - else if (value is int intVal) - { - newExpression = SyntaxFactory.LiteralExpression( - SyntaxKind.NumericLiteralExpression, - SyntaxFactory.Literal(intVal)); - } - else if (value is long longVal) - { - newExpression = SyntaxFactory.LiteralExpression( - SyntaxKind.NumericLiteralExpression, - SyntaxFactory.Literal(longVal)); - } - else if (value is double doubleVal) - { - newExpression = SyntaxFactory.LiteralExpression( - SyntaxKind.NumericLiteralExpression, - SyntaxFactory.Literal(doubleVal)); - } - else if (value is float floatVal) - { - newExpression = SyntaxFactory.LiteralExpression( - SyntaxKind.NumericLiteralExpression, - SyntaxFactory.Literal(floatVal)); - } - else if (value is decimal decimalVal) - { - newExpression = SyntaxFactory.LiteralExpression( - SyntaxKind.NumericLiteralExpression, - SyntaxFactory.Literal(decimalVal)); - } - else if (value is bool boolVal) - { - newExpression = SyntaxFactory.LiteralExpression( - boolVal ? SyntaxKind.TrueLiteralExpression : SyntaxKind.FalseLiteralExpression); - } - else if (value is string stringVal) - { - newExpression = SyntaxFactory.LiteralExpression( - SyntaxKind.StringLiteralExpression, - SyntaxFactory.Literal(stringVal)); - } - else if (value is char charVal) - { - newExpression = SyntaxFactory.LiteralExpression( - SyntaxKind.CharacterLiteralExpression, - SyntaxFactory.Literal(charVal)); - } - - if (newExpression != null) - { - return SyntaxFactory.EqualsValueClause(newExpression) - .WithLeadingTrivia(oldInitializer.GetLeadingTrivia()) - .WithTrailingTrivia(oldInitializer.GetTrailingTrivia()); - } - - return null; - } -} diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Compilation/ScriptCompilationBackend.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Compilation/ScriptCompilationBackend.cs index 5718ab0d..faa25ed6 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Compilation/ScriptCompilationBackend.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Compilation/ScriptCompilationBackend.cs @@ -75,14 +75,6 @@ internal static class ScriptCompilationBackend return assemblyStream; } - /// - /// Backwards-compatible overload that discards diagnostics. Prefer the - /// - /// overload at new call sites. - /// - internal static MemoryStream? CompileToAssembly(CompilationUnitSyntax compilationUnit, string hash) - => CompileToAssembly(compilationUnit, hash, out _); - /// /// Gets the set of s needed for compilation. /// diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BS2CFGConverter.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BS2CFGConverter.cs index 6ab5d2f7..8a3562ee 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BS2CFGConverter.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BS2CFGConverter.cs @@ -3,7 +3,6 @@ using KitX.Workflow.BlockScripting; using Serilog; -using static KitX.Workflow.BlockScripting.BlockScriptWellKnown.Functions; using KitX.Workflow.CFG; using KitX.Workflow.Blueprint; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/ControlFlowMapping.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/ControlFlowMapping.cs index 987c81af..63baf810 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/ControlFlowMapping.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/ControlFlowMapping.cs @@ -1,8 +1,6 @@ using KitX.Core.Contract.Workflow; using KitX.Workflow.CFG; -using static KitX.Workflow.BlockScripting.BlockScriptWellKnown.Functions; - namespace KitX.Workflow.Conversion; /// @@ -33,11 +31,11 @@ internal static class ControlFlowMapping /// FlowControlType → canonical function-name string (e.g. "Branch"). public static string ToFunctionName(FlowControlType type) => type switch { - FlowControlType.Branch => Branch, - FlowControlType.Loop => Loop, - FlowControlType.Switch => Switch, - FlowControlType.ToLoopCond => ToLoopCond, - FlowControlType.Break => Break, + FlowControlType.Branch => "Branch", + FlowControlType.Loop => "Loop", + FlowControlType.Switch => "Switch", + FlowControlType.ToLoopCond => "ToLoopCond", + FlowControlType.Break => "Break", _ => string.Empty }; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/ConversionContext.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/ConversionContext.cs index 8638622e..7476c4c6 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/ConversionContext.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/ConversionContext.cs @@ -23,17 +23,9 @@ internal class ConversionContext public List AllPubVars { get; set; } = new(); public List AutoPubVars { get; set; } = new(); public int PubVarCounter { get; set; } = 1; - public int BlockCounter { get; set; } = 0; // Phase 2: CFG block construction - public Dictionary ControlFlowMap { get; set; } = new(); - public List PendingControlFlowNodes { get; set; } = new(); public Dictionary LoopNodes { get; set; } = new(); - public Dictionary LoopBodyBlocks { get; set; } = new(); - public Dictionary BlockNameAssignments { get; set; } = new(); - - // Block scope fast path - public Dictionary ScopesByName { get; set; } = new(); /// /// Current loopback target ID when walking inside a loop body. diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/PipelineContext.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/PipelineContext.cs index 175e7fae..4476cd29 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/PipelineContext.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/PipelineContext.cs @@ -45,7 +45,6 @@ public class PipelineContext // --- Shared state --- public int NextPubVarCounter { get; set; } = 0; - public HashSet VisitedBlocks { get; set; } = new(); /// /// PubVar reuse tracking: fingerprint → assignment info. @@ -65,7 +64,6 @@ public class PipelineContext public Dictionary LoopConditions { get; set; } = new(); // --- Debug tracking --- - public List DebugLog { get; set; } = new(); /// /// User-facing diagnostics accumulated across BS→CFG→BP phases. Backend-bug-class @@ -84,12 +82,6 @@ public class PubVarAssignment public BlueprintNode SourceNode { get; set; } = null!; public BlueprintPin SourcePin { get; set; } = null!; public string StatementId { get; set; } = string.Empty; - - /// - /// Sub-assignments that are part of this expression (e.g. Get nodes that feed into a CallHelper). - /// Key = sub-expression fingerprint, Value = (node, pin, statementId). - /// - public Dictionary SubAssignments { get; set; } = new(); } /// @@ -117,11 +109,6 @@ public class ConditionInfo /// public string RawExpression { get; set; } = string.Empty; - /// - /// The fingerprint of the condition expression for reuse detection. - /// - public string Fingerprint { get; set; } = string.Empty; - /// /// The formatted statements that evaluate the condition (may be multiple if nested). /// The last one assigns to ConditionPubVar. diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Hosting/ServiceCollectionExtensions.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Hosting/ServiceCollectionExtensions.cs index 832a727a..812cc217 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Hosting/ServiceCollectionExtensions.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Hosting/ServiceCollectionExtensions.cs @@ -64,9 +64,6 @@ public static IServiceCollection AddKitXWorkflow(this IServiceCollection service // host (Dashboard) can pre-resolve it to force eager singleton construction. services.AddSingleton(sp => sp.GetRequiredService()); - // KCS File Service - services.AddSingleton(); - // Block Script Services services.AddSingleton(provider => { diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Hosting/ServiceLocator.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Hosting/ServiceLocator.cs index 06b66ce5..830cd3f9 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Hosting/ServiceLocator.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Hosting/ServiceLocator.cs @@ -63,10 +63,4 @@ public static void Initialize(IServiceProvider serviceProvider) /// public static T GetRequiredService() where T : notnull => ServiceProvider.GetRequiredService(); - - /// - /// Gets a service from the DI container, or null if not registered. - /// - public static T? GetService() where T : class - => ServiceProvider.GetService(); } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptServiceImpl.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Services/BlockScriptServiceImpl.cs similarity index 89% rename from KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptServiceImpl.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Services/BlockScriptServiceImpl.cs index 39c433bf..77610f29 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptServiceImpl.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Services/BlockScriptServiceImpl.cs @@ -56,12 +56,6 @@ public BlockScriptParseResult ParseBlockScript(string sourceCode) return BlockScriptParser.Parse(sourceCode); } - /// - public Task ParseBlockScriptAsync(string sourceCode) - { - return BlockScriptParser.ParseAsync(sourceCode); - } - /// public BlockScriptValidationResult ValidateBlockScript(string sourceCode) { @@ -105,24 +99,6 @@ public List ParseConstantsFromBlockScript(string sourceCode) return result; } - /// - public Task ExecuteBlockScriptAsync( - BlockScript script, - Dictionary? parameters = null, - CancellationToken cancellationToken = default) - { - return BlockScriptExecutor.ExecuteAsync(script, parameters, cancellationToken); - } - - /// - public Task ExecuteBlockScriptAsync( - string sourceCode, - Dictionary? parameters = null, - CancellationToken cancellationToken = default) - { - return ExecuteBlockScriptCoreAsync(sourceCode, null, parameters, cancellationToken); - } - /// public Task ExecuteBlockScriptAsync( string sourceCode, diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Services/KcsFileService.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Services/KcsFileService.cs deleted file mode 100644 index a9d1477d..00000000 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Services/KcsFileService.cs +++ /dev/null @@ -1,53 +0,0 @@ -using System.Text.Json; -using KitX.Core.Contract.Workflow; -using Serilog; - -namespace KitX.Workflow.Services; - -/// -/// KCS文件服务实现 - 仅负责KCS文件的读写 -/// -public class KcsFileService : IKcsFileService -{ - private static readonly JsonSerializerOptions _jsonOptions = new() - { - WriteIndented = true - }; - - /// - /// 加载KCS文件 - /// - public async System.Threading.Tasks.Task LoadKcsFileAsync(string filePath) - { - try - { - var json = await File.ReadAllTextAsync(filePath); - var kcs = JsonSerializer.Deserialize(json, _jsonOptions); - return kcs; - } - catch (Exception ex) - { - Log.Error(ex, "[KcsFileService] Error loading KCS file: {FilePath}", filePath); - return null; - } - } - - /// - /// 保存KCS文件 - /// - public async System.Threading.Tasks.Task SaveKcsFileAsync(string filePath, KcsFileFormat kcs) - { - try - { - var json = JsonSerializer.Serialize(kcs, _jsonOptions); - - await File.WriteAllTextAsync(filePath, json); - Log.Information("[KcsFileService] KCS file saved: {FilePath}", filePath); - } - catch (Exception ex) - { - Log.Error(ex, "[KcsFileService] Error saving KCS file: {FilePath}", filePath); - throw; - } - } -} diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Services/StrictScriptValidator.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Services/StrictScriptValidator.cs deleted file mode 100644 index 4aa77f4d..00000000 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Services/StrictScriptValidator.cs +++ /dev/null @@ -1,79 +0,0 @@ -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; - -namespace KitX.Workflow.Services; - -/// -/// 严格的脚本验证器 - 使用 CSharpSyntaxWalker 检查允许的语法 -/// -internal class StrictScriptValidator : CSharpSyntaxWalker -{ - public bool IsValid { get; private set; } = true; - public string ForbiddenReason { get; private set; } = ""; - - public override void Visit(SyntaxNode? node) - { - if (!IsValid || node == null) return; - - // 1. 允许的基础结构 - if (node is CompilationUnitSyntax || // 脚本根节点 - node is GlobalStatementSyntax || // 全局语句 - node is ExpressionStatementSyntax) // 表达式语句(如 a = 1; 或 Func();) - { - base.Visit(node); - return; - } - - // 2. 允许:变量定义与赋值 - // 包括:int a = 1; (LocalDeclaration) 和 a = 2; (AssignmentExpression) - if (node is LocalDeclarationStatementSyntax || - node is VariableDeclarationSyntax || - node is VariableDeclaratorSyntax || - node is EqualsValueClauseSyntax || // 变量初始化 = 值 - node is AssignmentExpressionSyntax || - node is IdentifierNameSyntax || // 变量名 - node is PredefinedTypeSyntax || // 基本类型关键字 (int, string) - node is LiteralExpressionSyntax) // 字面量 (123, "hello") - { - base.Visit(node); - return; - } - - // 3. 允许:函数调用 - // 包括:MyFunc(a, 10); - if (node is InvocationExpressionSyntax || - node is ArgumentListSyntax || - node is ArgumentSyntax || - node is MemberAccessExpressionSyntax || // 成员访问,如 obj.Member - node is MemberBindingExpressionSyntax || // 成员绑定,如 ?.Member - node is InterpolatedStringExpressionSyntax || // 字符串插值,如 $"Hello {name}" - node is InterpolatedStringTextSyntax || // 字符串插值文本部分 - node is InterpolationSyntax) // 插值表达式 {xxx} - { - base.Visit(node); - return; - } - - // 4. 允许:return 语句 // 不再允许,因为主程序的return语句没有存在的意义 - // if (node is ReturnStatementSyntax) - // { - // base.Visit(node); - // return; - // } - - // 5. 允许:类型转换 - if (node is CastExpressionSyntax) - { - base.Visit(node); - return; - } - - // 6. 禁止一切其他行为 - // 此时 node 可能是 BinaryExpressionSyntax (加减乘除), IfStatementSyntax (条件), WhileStatementSyntax (循环) 等 - IsValid = false; - var location = node.GetLocation(); - var lineSpan = location.GetLineSpan(); - ForbiddenReason = $"Unsupported syntax: {node.Kind()} at line {lineSpan.StartLinePosition.Line + 1}"; - } -} \ No newline at end of file diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Services/WorkflowManagementService.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Services/WorkflowManagementService.cs index 7b7374bd..2b56b799 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Services/WorkflowManagementService.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Services/WorkflowManagementService.cs @@ -26,31 +26,6 @@ internal WorkflowManagementService(WorkflowRuntimeState state, BlockScriptServic _blockScriptService = blockScriptService; } - /// - public IReadOnlyList GetWorkflows() - { - return _state.Workflows.ToList(); - } - - /// - public void AddWorkflow(IWorkflowCase workflow) - { - if (workflow != null && !_state.Workflows.Any(w => w.Id == workflow.Id)) - { - _state.Workflows.Add(workflow); - } - } - - /// - public void RemoveWorkflow(string workflowId) - { - var workflow = _state.Workflows.FirstOrDefault(w => w.Id == workflowId); - if (workflow != null) - { - _state.Workflows.Remove(workflow); - } - } - /// public async Task RunWorkflowAsync(string workflowId) { diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Services/WorkflowOutput.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Services/WorkflowOutput.cs index 5e852eb3..537a7711 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Services/WorkflowOutput.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Services/WorkflowOutput.cs @@ -39,9 +39,4 @@ public static string GetAndClear() } return sb.ToString(); } - - /// - /// 检查是否有输出 - /// - public static bool HasOutput => !_outputQueue.IsEmpty; } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Services/WorkflowPluginService.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Services/WorkflowPluginService.cs index 9867cb51..0fb46dc9 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Services/WorkflowPluginService.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Services/WorkflowPluginService.cs @@ -1,7 +1,6 @@ using KitX.Core.Contract.Workflow; using KitX.Core.Contract.Plugin; using KitX.Shared.CSharp.Plugin; -using Microsoft.CodeAnalysis.CSharp; using Serilog; namespace KitX.Workflow.Services; @@ -23,13 +22,6 @@ internal WorkflowPluginService(WorkflowRuntimeState state) _state = state; } - /// - public void UpdateAvailablePlugins(List plugins) - { - _state.AvailablePlugins = plugins ?? new List(); - Log.Information($"[WorkflowPluginService] Updated available plugins: {_state.AvailablePlugins.Count} plugins"); - } - /// public List ParseConstantsFromCode(string code) { @@ -96,57 +88,4 @@ public List ParseConstantsFromCode(string code) return valueStr; } } - - /// - public string ApplyConstantsToCode(string code, List constants) - { - if (string.IsNullOrWhiteSpace(code) || constants == null || !constants.Any()) - return code; - - var constantValues = new Dictionary(); - foreach (var constant in constants) - { - var typedValue = constant.UserValue; - if (typedValue != null && constant.Type != null) - { - typedValue = ConvertToTypedValue(typedValue, constant.Type); - } - constantValues[constant.Name] = typedValue; - } - - var tree = CSharpSyntaxTree.ParseText(code); - var root = tree.GetRoot(); - - var rewriter = new ConstantValueRewriter(constantValues); - var newRoot = rewriter.Visit(root); - - return newRoot.ToFullString(); - } - - /// - /// Converts a value to the specified type. - /// - private static object? ConvertToTypedValue(object? value, string type) - { - if (value == null) return null; - - return type.ToLowerInvariant() switch - { - "int" => Convert.ToInt32(value), - "long" => Convert.ToInt64(value), - "double" => Convert.ToDouble(value), - "float" => Convert.ToSingle(value), - "decimal" => Convert.ToDecimal(value), - "bool" or "boolean" => Convert.ToBoolean(value), - "string" => value.ToString(), - "char" => Convert.ToChar(value), - _ => value - }; - } - - /// - public string MergeHelperFunctions(string mainCode, List helperFunctions) - { - return BlockScripting.HelperFunctionCodeGenerator.MergeWithMainProgram(mainCode, helperFunctions); - } } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Services/WorkflowStorageService.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Services/WorkflowStorageService.cs index 4efe3f48..e341da99 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Services/WorkflowStorageService.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Services/WorkflowStorageService.cs @@ -128,18 +128,6 @@ public Task DeleteWorkflowAsync(string workflowId) return Task.CompletedTask; } - /// - public async Task RenameWorkflowAsync(string workflowId, string newName) - { - var data = await LoadWorkflowDataAsync(workflowId); - if (data != null) - { - data.Name = newName; - await SaveWorkflowDataAsync(workflowId, data); - Log.Information("[WorkflowStorageService] Renamed workflow {Id} to: {Name}", workflowId, newName); - } - } - /// public async Task> DiscoverWorkflowsAsync() { @@ -201,36 +189,6 @@ public string GetWorkflowFilePath(string workflowId) return Path.Combine(_storageDirectory, $"{workflowId}.kcs"); } - /// - public async Task PreloadCompiledScriptsAsync() - { - EnsureDirectoryExists(); - - var workflows = await DiscoverWorkflowsAsync(); - var totalLoaded = 0; - - var scriptService = ServiceLocator.GetRequiredService(); - - foreach (var workflow in workflows) - { - try - { - var count = scriptService.PreloadCompiledScripts(workflow.Id); - totalLoaded += count; - } - catch (Exception ex) - { - Log.Debug(ex, "[WorkflowStorageService] Error preloading compiled scripts for workflow {Id}", workflow.Id); - } - } - - if (totalLoaded > 0) - Log.Information("[WorkflowStorageService] Preloaded {Count} compiled scripts for {WfCount} workflows", - totalLoaded, workflows.Count); - - return totalLoaded; - } - private void EnsureDirectoryExists() { if (!Directory.Exists(_storageDirectory)) diff --git a/KitX Standard b/KitX Standard index 2b827022..82762139 160000 --- a/KitX Standard +++ b/KitX Standard @@ -1 +1 @@ -Subproject commit 2b8270229dcf690c0d24aa40943c156026f89431 +Subproject commit 82762139dc002a8cde6c5d5db84385414ee02eaf diff --git a/KitX.sln b/KitX.sln index fec90dc1..493201f4 100644 --- a/KitX.sln +++ b/KitX.sln @@ -131,8 +131,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "KitX.Sdk.Generators.CSharp" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Units", "Units", "{2E7D2566-EAFF-4921-A312-88CF0ABC40E6}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SyncCodes", "KitX SDK\Utils\SyncCodes\SyncCodes.csproj", "{4D8ED1CF-E2FF-4666-88D5-7D5300014301}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KitX.Cheese", ".cheese\KitX.Cheese.csproj", "{4FF6F614-F88E-4621-9A30-F6A75873218D}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Specific", "Specific", "{B2234097-D037-4126-9CC0-F7ECF3FEEEA5}" @@ -739,26 +737,6 @@ Global {DD575EF3-DF09-40A1-A89A-AB775D65A423}.Release|x64.Build.0 = Release|Any CPU {DD575EF3-DF09-40A1-A89A-AB775D65A423}.Release|x86.ActiveCfg = Release|Any CPU {DD575EF3-DF09-40A1-A89A-AB775D65A423}.Release|x86.Build.0 = Release|Any CPU - {4D8ED1CF-E2FF-4666-88D5-7D5300014301}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {4D8ED1CF-E2FF-4666-88D5-7D5300014301}.Debug|Any CPU.Build.0 = Debug|Any CPU - {4D8ED1CF-E2FF-4666-88D5-7D5300014301}.Debug|ARM.ActiveCfg = Debug|Any CPU - {4D8ED1CF-E2FF-4666-88D5-7D5300014301}.Debug|ARM.Build.0 = Debug|Any CPU - {4D8ED1CF-E2FF-4666-88D5-7D5300014301}.Debug|ARM64.ActiveCfg = Debug|Any CPU - {4D8ED1CF-E2FF-4666-88D5-7D5300014301}.Debug|ARM64.Build.0 = Debug|Any CPU - {4D8ED1CF-E2FF-4666-88D5-7D5300014301}.Debug|x64.ActiveCfg = Debug|Any CPU - {4D8ED1CF-E2FF-4666-88D5-7D5300014301}.Debug|x64.Build.0 = Debug|Any CPU - {4D8ED1CF-E2FF-4666-88D5-7D5300014301}.Debug|x86.ActiveCfg = Debug|Any CPU - {4D8ED1CF-E2FF-4666-88D5-7D5300014301}.Debug|x86.Build.0 = Debug|Any CPU - {4D8ED1CF-E2FF-4666-88D5-7D5300014301}.Release|Any CPU.ActiveCfg = Release|Any CPU - {4D8ED1CF-E2FF-4666-88D5-7D5300014301}.Release|Any CPU.Build.0 = Release|Any CPU - {4D8ED1CF-E2FF-4666-88D5-7D5300014301}.Release|ARM.ActiveCfg = Release|Any CPU - {4D8ED1CF-E2FF-4666-88D5-7D5300014301}.Release|ARM.Build.0 = Release|Any CPU - {4D8ED1CF-E2FF-4666-88D5-7D5300014301}.Release|ARM64.ActiveCfg = Release|Any CPU - {4D8ED1CF-E2FF-4666-88D5-7D5300014301}.Release|ARM64.Build.0 = Release|Any CPU - {4D8ED1CF-E2FF-4666-88D5-7D5300014301}.Release|x64.ActiveCfg = Release|Any CPU - {4D8ED1CF-E2FF-4666-88D5-7D5300014301}.Release|x64.Build.0 = Release|Any CPU - {4D8ED1CF-E2FF-4666-88D5-7D5300014301}.Release|x86.ActiveCfg = Release|Any CPU - {4D8ED1CF-E2FF-4666-88D5-7D5300014301}.Release|x86.Build.0 = Release|Any CPU {4FF6F614-F88E-4621-9A30-F6A75873218D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {4FF6F614-F88E-4621-9A30-F6A75873218D}.Debug|Any CPU.Build.0 = Debug|Any CPU {4FF6F614-F88E-4621-9A30-F6A75873218D}.Debug|ARM.ActiveCfg = Debug|Any CPU @@ -1043,7 +1021,6 @@ Global {FF0F9B42-7FFA-4113-AD0E-15955D89BFEA} = {69E9C41D-1848-4F9F-8C9C-0CAC1C167336} {DD575EF3-DF09-40A1-A89A-AB775D65A423} = {FF0F9B42-7FFA-4113-AD0E-15955D89BFEA} {2E7D2566-EAFF-4921-A312-88CF0ABC40E6} = {69E9C41D-1848-4F9F-8C9C-0CAC1C167336} - {4D8ED1CF-E2FF-4666-88D5-7D5300014301} = {2E7D2566-EAFF-4921-A312-88CF0ABC40E6} {B2234097-D037-4126-9CC0-F7ECF3FEEEA5} = {0194E969-1457-4C16-AFAB-D2753DFA07F0} {91F13EBC-53CE-4032-AAFC-268E90C32E5F} = {B2234097-D037-4126-9CC0-F7ECF3FEEEA5} {1B22641F-6CC9-4F00-BDBE-07EA81E5CF8F} = {7C64F32B-E1EE-45F7-94A7-DA2AD9288249} From 499751e78e2a7c244629c0ab2c64a2e5acce0ff6 Mon Sep 17 00:00:00 2001 From: StarInk Date: Sat, 20 Jun 2026 21:47:45 +0200 Subject: [PATCH 107/110] =?UTF-8?q?=F0=9F=A7=A9=20Refactor(Workflow):=20?= =?UTF-8?q?=E6=8E=A7=E5=88=B6=E6=B5=81=E5=BD=A2=E6=80=81=E6=B6=88=E6=AD=A7?= =?UTF-8?q?+Models=E5=BD=92=E4=BD=8D+=E8=A1=A8=E8=BE=BE=E5=BC=8F=E6=9E=84?= =?UTF-8?q?=E5=BB=BA=E5=99=A8=E6=94=B6=E6=95=9B-=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E7=BB=84=E7=BB=87=E9=87=8D=E6=9E=84=E9=98=B6=E6=AE=B5L14?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase A - 低风险机械改动: - BlockScriptServiceImpl -> BlockScriptService(消除唯一Impl后缀异常) - BlueprintDebugger.cs -> Services/(运行时状态控制器,与RealPluginManager并列) - BlueprintDebugContext内联进ControlFlowGraph(1字段DTO,DebugStatementToNodeId) - 删除BlueprintToBlockScriptConverter死字段_cfg2bs - 修复3处过时文档(BlockScriptSerializer/PipelineContext/ControlFlowMapping) Phase B - CFGStatementKind控制流形态消歧(核心): - FlowControlType重命名为图结构语义名: Branch->ConditionalJump, Loop->IterativeJump, Switch->IndexedDispatch, Break->LoopExit, ToLoopCond->LoopBackedge, Return->ScriptReturn (消除与builtin函数名同名混淆,命名按图/边语义而非函数) - IBuiltinFunctionDefinition新增FlowControlShape(默认null),StatementKind改为派生 - CFGStatement新增FlowControlShape属性,LowerToCFG/合成语句时填充 - 所有控制流消费点改查FlowControlShape: CFG2CSConverter类型推断/BP2CFGConverter块分类 与边语义/CFGConditionDuplicator/CFGBlock.EndsWithControlFlow/GetEdgeType/RegenerateBranchSource - 删除6个registry迁移遗留死值: Print/Set/Get/Pause/PluginCallWithTarget/TryGetDevice (保留5个控制流Kind作派生诊断标签+Assignment/Expression/NextBlockAssignment值分类) - ControlFlowMapping简化文档+明确ToFunctionName为唯一字符串源 - 新增控制流builtin只需设FlowControlShape,零enum改动零散弹 Phase C - Models提升: - Abstractions/Models/ -> 根Models/(13文件,namespace Abstractions.Models->Models) - Abstractions/回归接口本义(已证伪跨Contract边界说法:Dashboard走接口不碰模型) - GlobalUsings+19文件显式引用更新 Phase D - 表达式构建器收敛+registry单例: - CSEmitContext新增GetInvocation/PluginCallExpression/PluginCallWithTargetExpression - 3个BuiltinFunctions从CFG2CSConverter.X(...)改ctx.X(...),消除叶子层反向依赖929行converter - BuiltinFunctionRegistry新增Instance懒单例,CFG2CSConverter/CSCompiler改用之 (消除4次Discover反射扫描与实例漂移) Phase E - ExprUtils性能优化: - ParseExpression/ParseStatement加ConcurrentDictionary memoize(热点路径,避免重复Roslyn解析) - IsCharacterLiteral改用SyntaxFactory.ParseToken(比表达式树解析便宜得多) 清理: KitX.sln移除SyncCodes悬空Build配置行 验证: Workflow/Core.Contract/Test/Dashboard四个项目0错误构建通过 测试: A-W全部23个测试通过(含控制流K/L/M/N/P/Q/R/S/T/V) BREAKING CHANGE: FlowControlType枚举值重命名(Branch->ConditionalJump等),外部引用需更新 Date: 2026-06-20 Author: StarInk --- .../KitX.Workflow.Test/KcsCompileTest.cs | 6 +-- .../KitX.Workflow.Test/Program.cs | 6 +-- .../Abstractions/IBlockScriptExecutor.cs | 2 +- .../Abstractions/IBlockScriptParser.cs | 2 +- .../Abstractions/IBlueprintConverters.cs | 2 +- .../IBlueprintToBlockScriptConverter.cs | 2 +- .../IBuiltinFunctionDefinition.cs | 28 ++++++++-- .../Abstractions/Models/FlowControlType.cs | 38 -------------- .../BlockScripting/BlockLinker.cs | 6 +-- .../BlockScripting/BlockStatementExtractor.cs | 2 +- .../BlockScripting/BuiltinFunctionRegistry.cs | 8 +++ .../Blueprint/BlueprintService.cs | 8 +-- .../BuiltinFunctions/BranchFunction.cs | 6 +-- .../BuiltinFunctions/BreakFunction.cs | 4 +- .../CreateWorkflowFunction.cs | 1 - .../BuiltinFunctions/FlipFunction.cs | 4 +- .../BuiltinFunctions/GetFunction.cs | 2 +- .../GetPluginInfoByNameFunction.cs | 1 - .../BuiltinFunctions/InstallPluginFunction.cs | 1 - .../BuiltinFunctions/JsonGetFieldFunction.cs | 1 - .../ListPluginNamesFunction.cs | 1 - .../BuiltinFunctions/ListWorkflowsFunction.cs | 1 - .../BuiltinFunctions/LoopFunction.cs | 6 +-- .../BuiltinFunctions/PauseFunction.cs | 1 - .../BuiltinFunctions/PluginCallFunction.cs | 3 +- .../PluginCallWithTargetFunction.cs | 3 +- .../BuiltinFunctions/PrintFunction.cs | 1 - .../BuiltinFunctions/ReadTextFileFunction.cs | 1 - .../BuiltinFunctions/RunWorkflowFunction.cs | 1 - .../BuiltinFunctions/SetFunction.cs | 1 - .../BuiltinFunctions/StartPluginFunction.cs | 1 - .../BuiltinFunctions/StopPluginFunction.cs | 1 - .../BuiltinFunctions/StopWorkflowFunction.cs | 1 - .../BuiltinFunctions/StringConcatFunction.cs | 1 - .../BuiltinFunctions/SwitchFunction.cs | 6 +-- .../BuiltinFunctions/ToLoopCondFunction.cs | 8 +-- .../BuiltinFunctions/TryGetDeviceFunction.cs | 3 +- .../BuiltinFunctions/WriteTextFileFunction.cs | 1 - .../CFG/BlueprintDebugContext.cs | 6 --- .../KitX.Workflow/CFG/CFGBlock.cs | 5 +- .../KitX.Workflow/CFG/CFGStatement.cs | 48 ++++++++--------- .../KitX.Workflow/CFG/ControlFlowGraph.cs | 8 ++- .../Compilation/CFG2CSConverter.cs | 8 +-- .../KitX.Workflow/Compilation/CSCompiler.cs | 3 +- .../Compilation/CSEmitContext.cs | 12 +++++ .../Compilation/ScriptCompilationBackend.cs | 4 +- .../Conversion/BP2CFGConverter.cs | 52 ++++++++++--------- .../Conversion/BS2CFGConverter.cs | 9 ++-- .../Conversion/BlockScriptSerializer.cs | 14 ++--- .../BlueprintToBlockScriptConverter.cs | 5 +- .../Conversion/CFG2BPConverter.cs | 4 +- .../Conversion/CFG2BSConverter.cs | 5 +- .../Conversion/CFGConditionDuplicator.cs | 6 +-- .../KitX.Workflow/Conversion/CFGPipeline.cs | 2 +- .../Conversion/ControlFlowMapping.cs | 48 ++++++++--------- .../KitX.Workflow/Conversion/ExprUtils.cs | 29 ++++++++--- .../Conversion/PipelineContext.cs | 4 +- .../KitX.Workflow/GlobalUsings.cs | 6 +-- .../Hosting/ServiceCollectionExtensions.cs | 4 +- .../Models/BlockDefinition.cs | 2 +- .../{Abstractions => }/Models/BlockScript.cs | 2 +- .../{Abstractions => }/Models/BlockType.cs | 2 +- .../Models/ConversionDiag.cs | 2 +- .../Models/ConversionDiagnostics.cs | 2 +- .../KitX.Workflow/Models/FlowControlType.cs | 44 ++++++++++++++++ .../Models/PluginCallInfo.cs | 2 +- .../Models/Results/BlockExecutionResult.cs | 2 +- .../Models/Results/BlockScriptParseResult.cs | 2 +- .../Models/Statements/BlockStatement.cs | 2 +- .../Models/Statements/ExpressionStatement.cs | 2 +- .../Models/Statements/FlowControlStatement.cs | 12 ++--- .../Models/VariableDeclaration.cs | 2 +- ...ptServiceImpl.cs => BlockScriptService.cs} | 8 +-- .../BlueprintDebugger.cs | 0 .../Services/WorkflowManagementService.cs | 4 +- .../Services/WorkflowScriptService.cs | 18 +++---- KitX.sln | 2 - 77 files changed, 302 insertions(+), 261 deletions(-) delete mode 100644 KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/FlowControlType.cs delete mode 100644 KitX Clients/KitX Workflow/KitX.Workflow/CFG/BlueprintDebugContext.cs rename KitX Clients/KitX Workflow/KitX.Workflow/{Abstractions => }/Models/BlockDefinition.cs (96%) rename KitX Clients/KitX Workflow/KitX.Workflow/{Abstractions => }/Models/BlockScript.cs (98%) rename KitX Clients/KitX Workflow/KitX.Workflow/{Abstractions => }/Models/BlockType.cs (93%) rename KitX Clients/KitX Workflow/KitX.Workflow/{Abstractions => }/Models/ConversionDiag.cs (95%) rename KitX Clients/KitX Workflow/KitX.Workflow/{Abstractions => }/Models/ConversionDiagnostics.cs (98%) create mode 100644 KitX Clients/KitX Workflow/KitX.Workflow/Models/FlowControlType.cs rename KitX Clients/KitX Workflow/KitX.Workflow/{Abstractions => }/Models/PluginCallInfo.cs (97%) rename KitX Clients/KitX Workflow/KitX.Workflow/{Abstractions => }/Models/Results/BlockExecutionResult.cs (95%) rename KitX Clients/KitX Workflow/KitX.Workflow/{Abstractions => }/Models/Results/BlockScriptParseResult.cs (94%) rename KitX Clients/KitX Workflow/KitX.Workflow/{Abstractions => }/Models/Statements/BlockStatement.cs (92%) rename KitX Clients/KitX Workflow/KitX.Workflow/{Abstractions => }/Models/Statements/ExpressionStatement.cs (95%) rename KitX Clients/KitX Workflow/KitX.Workflow/{Abstractions => }/Models/Statements/FlowControlStatement.cs (86%) rename KitX Clients/KitX Workflow/KitX.Workflow/{Abstractions => }/Models/VariableDeclaration.cs (93%) rename KitX Clients/KitX Workflow/KitX.Workflow/Services/{BlockScriptServiceImpl.cs => BlockScriptService.cs} (95%) rename KitX Clients/KitX Workflow/KitX.Workflow/{BlockScripting => Services}/BlueprintDebugger.cs (100%) diff --git a/KitX Clients/KitX Workflow/KitX.Workflow.Test/KcsCompileTest.cs b/KitX Clients/KitX Workflow/KitX.Workflow.Test/KcsCompileTest.cs index 6d55085e..21aa1fb0 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow.Test/KcsCompileTest.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow.Test/KcsCompileTest.cs @@ -8,9 +8,9 @@ using KitX.Workflow.BlockScripting; using KitX.Workflow.Compilation; using KitX.Workflow.Abstractions; -using KitX.Workflow.Abstractions.Models; -using KitX.Workflow.Abstractions.Models.Statements; -using KitX.Workflow.Abstractions.Models.Results; +using KitX.Workflow.Models; +using KitX.Workflow.Models.Statements; +using KitX.Workflow.Models.Results; using KitX.Workflow.Blueprint; using KitX.Workflow.CFG; using KitX.Workflow.Conversion; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow.Test/Program.cs b/KitX Clients/KitX Workflow/KitX.Workflow.Test/Program.cs index c1132d79..93130944 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow.Test/Program.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow.Test/Program.cs @@ -10,9 +10,9 @@ using KitX.Workflow.BlockScripting; using KitX.Workflow.Compilation; using KitX.Workflow.Abstractions; -using KitX.Workflow.Abstractions.Models; -using KitX.Workflow.Abstractions.Models.Statements; -using KitX.Workflow.Abstractions.Models.Results; +using KitX.Workflow.Models; +using KitX.Workflow.Models.Statements; +using KitX.Workflow.Models.Results; using KitX.Workflow.Conversion; namespace KitX.Workflow.Test; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/IBlockScriptExecutor.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/IBlockScriptExecutor.cs index bae0289e..bdf9520c 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/IBlockScriptExecutor.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/IBlockScriptExecutor.cs @@ -2,7 +2,7 @@ using System.Threading; using System.Threading.Tasks; using KitX.Core.Contract.Workflow; -using KitX.Workflow.Abstractions.Models; +using KitX.Workflow.Models; namespace KitX.Workflow.Abstractions; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/IBlockScriptParser.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/IBlockScriptParser.cs index 751bb74a..77344226 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/IBlockScriptParser.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/IBlockScriptParser.cs @@ -1,6 +1,6 @@ using System.Threading.Tasks; using KitX.Core.Contract.Workflow; -using KitX.Workflow.Abstractions.Models.Results; +using KitX.Workflow.Models.Results; namespace KitX.Workflow.Abstractions; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/IBlueprintConverters.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/IBlueprintConverters.cs index 9d1f4651..e5c2d54a 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/IBlueprintConverters.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/IBlueprintConverters.cs @@ -21,5 +21,5 @@ public interface IBlockScriptToBlueprintConverter /// /// Parsed BlockScript /// Converted Blueprint - KitX.Core.Contract.Workflow.Blueprint Convert(KitX.Workflow.Abstractions.Models.BlockScript script); + KitX.Core.Contract.Workflow.Blueprint Convert(KitX.Workflow.Models.BlockScript script); } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/IBlueprintToBlockScriptConverter.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/IBlueprintToBlockScriptConverter.cs index 72140b3c..a6c45b78 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/IBlueprintToBlockScriptConverter.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/IBlueprintToBlockScriptConverter.cs @@ -18,5 +18,5 @@ public interface IBlueprintToBlockScriptConverter /// /// Blueprint to convert /// Parsed BlockScript - KitX.Workflow.Abstractions.Models.BlockScript ConvertToBlockScript(KitX.Core.Contract.Workflow.Blueprint blueprint); + KitX.Workflow.Models.BlockScript ConvertToBlockScript(KitX.Core.Contract.Workflow.Blueprint blueprint); } \ No newline at end of file diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/IBuiltinFunctionDefinition.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/IBuiltinFunctionDefinition.cs index dc5327a6..1fc34cea 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/IBuiltinFunctionDefinition.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/IBuiltinFunctionDefinition.cs @@ -30,13 +30,32 @@ public interface IBuiltinFunctionDefinition /// bool IsNonExtractable { get; } - // ─── 语句类型映射 ───────────────────────────────── + // ─── 控制流形态 ───────────────────────────────── /// - /// 对应的 CFGStatementKind。用于 BS2CFGConverter 确定语句类型, - /// 以及 CFG2BPConverter 选择节点创建策略。 + /// 该内置函数产生的 CFG 控制流形态(图结构角色)。非控制流函数返回 null(默认)。 + /// 这是控制流语义的权威来源——消费端(BP2CFGConverter/CFG2BPConverter/CFGConditionDuplicator/ + /// CFG2CSConverter 的类型推断等)应查询此属性而非 switch(CFGStatementKind)。 + /// 新增控制流内置函数只需覆写此属性,无需修改 CFGStatementKind 枚举或散弹式 switch。 /// - CFGStatementKind StatementKind { get; } + FlowControlType? FlowControlShape => null; + + // ─── 语句类型映射(派生)───────────────────────── + + /// + /// 对应的 CFGStatementKind。现在由 派生: + /// 控制流形态映射到对应的 Kind;非控制流默认 Expression。 + /// 保留供尚未迁移的旧消费点使用(见 Phase B.3 清理)。 + /// + CFGStatementKind StatementKind => FlowControlShape switch + { + FlowControlType.ConditionalJump => CFGStatementKind.Branch, + FlowControlType.IterativeJump => CFGStatementKind.Loop, + FlowControlType.IndexedDispatch => CFGStatementKind.Switch, + FlowControlType.LoopBackedge => CFGStatementKind.ToLoopCond, + FlowControlType.LoopExit => CFGStatementKind.Break, + _ => CFGStatementKind.Expression + }; /// /// 此内置函数在蓝图中物化为哪种节点类型。默认 ; @@ -125,6 +144,7 @@ List LowerToCFG( { BlockName = blockName, Kind = StatementKind, + FlowControlShape = FlowControlShape, FunctionName = FunctionName, Arguments = expandedArgs.ToList(), PubVarTarget = assignedVar, diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/FlowControlType.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/FlowControlType.cs deleted file mode 100644 index 264f818a..00000000 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/FlowControlType.cs +++ /dev/null @@ -1,38 +0,0 @@ -namespace KitX.Workflow.Abstractions.Models; - -/// -/// Flow control statement types -/// -public enum FlowControlType -{ - /// - /// Branch to another block based on condition - /// - Branch, - - /// - /// Loop while condition is true (Loop has three args: condition, trueBlock, falseBlock) - /// - Loop, - - /// - /// Return from script execution - /// - Return, - - /// - /// Break from current loop - /// - Break, - - /// - /// To loop condition - marks the end of a loop body and returns to loop condition - /// - ToLoopCond, - - /// - /// Switch to one of N blocks by integer index (default on out-of-range). - /// Syntax: Switch(selector, "defaultBlock", "b0", "b1", ...) - /// - Switch -} \ No newline at end of file diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockLinker.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockLinker.cs index 1bc6b43d..ff5af7ae 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockLinker.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockLinker.cs @@ -28,9 +28,9 @@ public static void LinkBlocksSequentially(BlockScript script) // A block ending with Branch/Loop should NOT fall through var lastStatement = currentBlock.Statements.LastOrDefault(); if (lastStatement is FlowControlStatement flowStmt && - (flowStmt.ControlType == FlowControlType.Branch || - flowStmt.ControlType == FlowControlType.Loop || - flowStmt.ControlType == FlowControlType.Return)) + (flowStmt.ControlType == FlowControlType.ConditionalJump || + flowStmt.ControlType == FlowControlType.IterativeJump || + flowStmt.ControlType == FlowControlType.ScriptReturn)) { continue; } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockStatementExtractor.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockStatementExtractor.cs index 35efeb69..ae06778a 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockStatementExtractor.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockStatementExtractor.cs @@ -147,7 +147,7 @@ private void ExtractStatements( { LineNumber = returnStmt.GetLineNumber(), SourceCode = returnStmt.ToFullString(), - ControlType = FlowControlType.Return, + ControlType = FlowControlType.ScriptReturn, ConditionExpression = returnStmt.Expression?.ToString() ?? string.Empty }); break; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BuiltinFunctionRegistry.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BuiltinFunctionRegistry.cs index 5144f062..9b0fe5f4 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BuiltinFunctionRegistry.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BuiltinFunctionRegistry.cs @@ -11,6 +11,14 @@ public class BuiltinFunctionRegistry { private readonly Dictionary _functions = new(); + /// + /// 进程级共享单例,懒初始化一次 (反射扫描本程序集)。 + /// 编译期静态消费者(CFG2CSConverter/CSCompiler)使用此单例,避免各自重复 Discover + /// 造成的多次反射开销与实例漂移。DI 路径仍可独立注册(服务消费者通过注入获取)。 + /// + public static BuiltinFunctionRegistry Instance { get; } = + new Lazy(() => Discover(typeof(BuiltinFunctionRegistry).Assembly)).Value; + /// 注册一个函数定义 public void Register(IBuiltinFunctionDefinition definition) { diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Blueprint/BlueprintService.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Blueprint/BlueprintService.cs index 9308f93f..7bd8b873 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Blueprint/BlueprintService.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Blueprint/BlueprintService.cs @@ -80,8 +80,8 @@ public async Task ExecuteBlueprintAsync(KitX.Core.Co Log.Debug("[BlueprintService] Using BP→CFG→CS direct path"); var result = await bse.ExecuteFromCFGAsync(blockScript, concrete.LastCFG, null, CancellationToken.None); - if (concrete.LastCFG.DebugContext != null) - result.DebugNodeMapping = new Dictionary(concrete.LastCFG.DebugContext.StatementToNodeId); + if (concrete.LastCFG.DebugStatementToNodeId != null) + result.DebugNodeMapping = new Dictionary(concrete.LastCFG.DebugStatementToNodeId); return result; } @@ -106,8 +106,8 @@ public Dictionary GetDebugNodeMapping(KitX.Core.Contract.Workflo try { var blockScript = _toBlockScriptConverter.ConvertToBlockScript(blueprint); - if (_toBlockScriptConverter is BlueprintToBlockScriptConverter concrete && concrete.LastCFG?.DebugContext != null) - return new Dictionary(concrete.LastCFG.DebugContext.StatementToNodeId); + if (_toBlockScriptConverter is BlueprintToBlockScriptConverter concrete && concrete.LastCFG?.DebugStatementToNodeId != null) + return new Dictionary(concrete.LastCFG.DebugStatementToNodeId); return new Dictionary(); } catch (Exception ex) diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/BranchFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/BranchFunction.cs index b3eceb0f..76c8f3a3 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/BranchFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/BranchFunction.cs @@ -16,7 +16,7 @@ public class BranchFunction : IBuiltinFunctionDefinition public bool IsFlowControl => true; public bool IsNonExtractable => false; public bool IsBlockTerminator => true; - public CFGStatementKind StatementKind => CFGStatementKind.Branch; + public FlowControlType? FlowControlShape => FlowControlType.ConditionalJump; public double NodeWidth => 120; public double NodeHeight => 80; @@ -37,7 +37,7 @@ public class BranchFunction : IBuiltinFunctionDefinition { LineNumber = lineNumber, SourceCode = exprText ?? invoke.ToFullString(), - ControlType = FlowControlType.Branch + ControlType = FlowControlType.ConditionalJump }; if (args.Count >= 1) stmt.ConditionExpression = args[0].Expression.ToString(); if (args.Count >= 2) stmt.TrueBlockName = ExprUtils.GetStringLiteralValue(args[1].Expression) ?? string.Empty; @@ -78,7 +78,7 @@ public List EmitStatements(CFGStatement stmt, CSEmitContext ctx var condition = helper.GetInputValue(node, "Condition"); return new FlowControlStatement { - ControlType = FlowControlType.Branch, + ControlType = FlowControlType.ConditionalJump, ConditionExpression = condition, SourceCode = $"Branch({condition}, \"\", \"\");", LineNumber = 1 diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/BreakFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/BreakFunction.cs index 97963aa0..e94745db 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/BreakFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/BreakFunction.cs @@ -16,7 +16,7 @@ public class BreakFunction : IBuiltinFunctionDefinition public bool IsFlowControl => true; public bool IsBlockTerminator => true; public bool IsNonExtractable => true; - public CFGStatementKind StatementKind => CFGStatementKind.Break; + public FlowControlType? FlowControlShape => FlowControlType.LoopExit; public double NodeWidth => 100; public double NodeHeight => 40; @@ -38,7 +38,7 @@ public List EmitStatements(CFGStatement stmt, CSEmitContext ctx { return new FlowControlStatement { - ControlType = FlowControlType.Break, + ControlType = FlowControlType.LoopExit, SourceCode = "Break();", LineNumber = 1 }; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/CreateWorkflowFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/CreateWorkflowFunction.cs index c14da422..aa14af4b 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/CreateWorkflowFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/CreateWorkflowFunction.cs @@ -13,7 +13,6 @@ public class CreateWorkflowFunction : IBuiltinFunctionDefinition public string DisplayName => "Create Workflow"; public bool IsFlowControl => false; public bool IsNonExtractable => false; // Value-producing (has Return pin) — can be nested as an expression - public CFGStatementKind StatementKind => CFGStatementKind.Expression; public double NodeWidth => 180; public double NodeHeight => 80; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/FlipFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/FlipFunction.cs index 6dc4fe1d..4eb3b1b0 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/FlipFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/FlipFunction.cs @@ -20,7 +20,7 @@ public class FlipFunction : IBuiltinFunctionDefinition public bool IsFlowControl => true; public bool IsNonExtractable => true; public bool IsBlockTerminator => true; - public CFGStatementKind StatementKind => CFGStatementKind.Branch; + public FlowControlType? FlowControlShape => FlowControlType.ConditionalJump; public double NodeWidth => 120; public double NodeHeight => 80; @@ -41,7 +41,7 @@ public class FlipFunction : IBuiltinFunctionDefinition { LineNumber = lineNumber, SourceCode = exprText ?? invoke.ToFullString(), - ControlType = FlowControlType.Branch // Reuse Branch type for cross-block routing + ControlType = FlowControlType.ConditionalJump // Reuse ConditionalJump shape for cross-block routing }; if (args.Count >= 1) stmt.TrueBlockName = ExprUtils.GetStringLiteralValue(args[0].Expression) ?? string.Empty; if (args.Count >= 2) stmt.FalseBlockName = ExprUtils.GetStringLiteralValue(args[1].Expression) ?? string.Empty; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/GetFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/GetFunction.cs index 76740bc8..b8d8c085 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/GetFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/GetFunction.cs @@ -41,7 +41,7 @@ public List EmitStatements(CFGStatement stmt, CSEmitContext ctx // Without this, Get always returns object, and passing it to a function expecting // string/int/bool causes CS1503. Falls back to object if the variable is unknown. var typeName = ctx.PubVarTypes.GetValueOrDefault(varName, "object"); - return ctx.EmitValueAssignment(stmt.PubVarTarget, CFG2CSConverter.BuildGetInvocation(varName, typeName)); + return ctx.EmitValueAssignment(stmt.PubVarTarget, ctx.GetInvocation(varName, typeName)); } public List LowerToCFG( diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/GetPluginInfoByNameFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/GetPluginInfoByNameFunction.cs index fd82b552..0f402783 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/GetPluginInfoByNameFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/GetPluginInfoByNameFunction.cs @@ -15,7 +15,6 @@ public class GetPluginInfoByNameFunction : IBuiltinFunctionDefinition public string DisplayName => "Get Plugin Info"; public bool IsFlowControl => false; public bool IsNonExtractable => false; // Value-producing (has Return pin) — can be nested as an expression - public CFGStatementKind StatementKind => CFGStatementKind.Expression; public double NodeWidth => 160; public double NodeHeight => 60; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/InstallPluginFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/InstallPluginFunction.cs index fcc8149e..3c042372 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/InstallPluginFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/InstallPluginFunction.cs @@ -14,7 +14,6 @@ public class InstallPluginFunction : IBuiltinFunctionDefinition public string DisplayName => "Install Plugin"; public bool IsFlowControl => false; public bool IsNonExtractable => false; // Value-producing (has Return pin) — can be nested as an expression - public CFGStatementKind StatementKind => CFGStatementKind.Expression; public double NodeWidth => 140; public double NodeHeight => 60; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/JsonGetFieldFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/JsonGetFieldFunction.cs index bff91651..ebe2ea23 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/JsonGetFieldFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/JsonGetFieldFunction.cs @@ -14,7 +14,6 @@ public class JsonGetFieldFunction : IBuiltinFunctionDefinition public string DisplayName => "JSON Get Field"; public bool IsFlowControl => false; public bool IsNonExtractable => false; // Value-producing (has Return pin) — can be nested as an expression - public CFGStatementKind StatementKind => CFGStatementKind.Expression; public double NodeWidth => 160; public double NodeHeight => 80; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ListPluginNamesFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ListPluginNamesFunction.cs index bc7a4919..d03f7adf 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ListPluginNamesFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ListPluginNamesFunction.cs @@ -15,7 +15,6 @@ public class ListPluginNamesFunction : IBuiltinFunctionDefinition public string DisplayName => "List Plugin Names"; public bool IsFlowControl => false; public bool IsNonExtractable => false; // Value-producing (has Return pin) — can be nested as an expression - public CFGStatementKind StatementKind => CFGStatementKind.Expression; public double NodeWidth => 140; public double NodeHeight => 60; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ListWorkflowsFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ListWorkflowsFunction.cs index c035f119..d153e74a 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ListWorkflowsFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ListWorkflowsFunction.cs @@ -14,7 +14,6 @@ public class ListWorkflowsFunction : IBuiltinFunctionDefinition public string DisplayName => "List Workflows"; public bool IsFlowControl => false; public bool IsNonExtractable => false; // Value-producing (has Return pin) — can be nested as an expression - public CFGStatementKind StatementKind => CFGStatementKind.Expression; public double NodeWidth => 140; public double NodeHeight => 60; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/LoopFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/LoopFunction.cs index 3fa0b990..f8a4a5d2 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/LoopFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/LoopFunction.cs @@ -17,7 +17,7 @@ public class LoopFunction : IBuiltinFunctionDefinition public bool IsFlowControl => true; public bool IsNonExtractable => false; public bool IsBlockTerminator => true; - public CFGStatementKind StatementKind => CFGStatementKind.Loop; + public FlowControlType? FlowControlShape => FlowControlType.IterativeJump; public double NodeWidth => 120; public double NodeHeight => 80; @@ -38,7 +38,7 @@ public class LoopFunction : IBuiltinFunctionDefinition { LineNumber = lineNumber, SourceCode = exprText ?? invoke.ToFullString(), - ControlType = FlowControlType.Loop + ControlType = FlowControlType.IterativeJump }; if (args.Count >= 1) stmt.ConditionExpression = args[0].Expression.ToString(); if (args.Count >= 2) stmt.TrueBlockName = ExprUtils.GetStringLiteralValue(args[1].Expression) ?? string.Empty; @@ -82,7 +82,7 @@ public List EmitStatements(CFGStatement stmt, CSEmitContext ctx var condition = helper.GetInputValue(node, "Condition"); return new FlowControlStatement { - ControlType = FlowControlType.Loop, + ControlType = FlowControlType.IterativeJump, ConditionExpression = condition, SourceCode = $"Loop({condition}, \"\", \"\");", LineNumber = 1 diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PauseFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PauseFunction.cs index 8f2b961f..3858b89d 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PauseFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PauseFunction.cs @@ -15,7 +15,6 @@ public class PauseFunction : IBuiltinFunctionDefinition public string DisplayName => "Pause"; public bool IsFlowControl => false; public bool IsNonExtractable => true; - public CFGStatementKind StatementKind => CFGStatementKind.Pause; public double NodeWidth => 100; public double NodeHeight => 50; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PluginCallFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PluginCallFunction.cs index 8c671444..fe5cb1a8 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PluginCallFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PluginCallFunction.cs @@ -26,7 +26,6 @@ public class PluginCallFunction : IBuiltinFunctionDefinition // false matches PluginCallWithTarget/Get/TryGetDevice — value-producing builtins // that CAN be nested. Set/Print/Pause stay true (pure side-effects, no return). public bool IsNonExtractable => false; - public CFGStatementKind StatementKind => CFGStatementKind.Expression; public double NodeWidth => 140; public double NodeHeight => 80; @@ -44,7 +43,7 @@ public class PluginCallFunction : IBuiltinFunctionDefinition public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; public List EmitStatements(CFGStatement stmt, CSEmitContext ctx) - => ctx.EmitValueAssignment(stmt.PubVarTarget, CFG2CSConverter.BuildPluginCallExpression(stmt, ctx.PubVarTypes)); + => ctx.EmitValueAssignment(stmt.PubVarTarget, ctx.PluginCallExpression(stmt)); public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) { diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PluginCallWithTargetFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PluginCallWithTargetFunction.cs index 511c2e37..bca907b6 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PluginCallWithTargetFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PluginCallWithTargetFunction.cs @@ -19,7 +19,6 @@ public class PluginCallWithTargetFunction : IBuiltinFunctionDefinition public string DisplayName => "PluginCallWithTarget"; public bool IsFlowControl => false; public bool IsNonExtractable => false; - public CFGStatementKind StatementKind => CFGStatementKind.PluginCallWithTarget; public BuiltinNodeKind NodeKind => BuiltinNodeKind.Call; // Preserve prior early-route behavior: identical cross-device calls dedup to one node. public string? GetReuseKey(CFGStatement stmt) => stmt.Fingerprint; @@ -41,7 +40,7 @@ public class PluginCallWithTargetFunction : IBuiltinFunctionDefinition public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; public List EmitStatements(CFGStatement stmt, CSEmitContext ctx) - => ctx.EmitValueAssignment(stmt.PubVarTarget, CFG2CSConverter.BuildPluginCallWithTargetExpression(stmt, ctx.PubVarTypes)); + => ctx.EmitValueAssignment(stmt.PubVarTarget, ctx.PluginCallWithTargetExpression(stmt)); public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) { diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PrintFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PrintFunction.cs index f39c3105..5f49665c 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PrintFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PrintFunction.cs @@ -16,7 +16,6 @@ public class PrintFunction : IBuiltinFunctionDefinition public string DisplayName => "Print"; public bool IsFlowControl => false; public bool IsNonExtractable => true; - public CFGStatementKind StatementKind => CFGStatementKind.Print; public double NodeWidth => 100; public double NodeHeight => 50; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ReadTextFileFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ReadTextFileFunction.cs index fdf657f1..2c2429ac 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ReadTextFileFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ReadTextFileFunction.cs @@ -13,7 +13,6 @@ public class ReadTextFileFunction : IBuiltinFunctionDefinition public string DisplayName => "Read Text File"; public bool IsFlowControl => false; public bool IsNonExtractable => false; // Value-producing (has Return pin) — can be nested as an expression - public CFGStatementKind StatementKind => CFGStatementKind.Expression; public double NodeWidth => 140; public double NodeHeight => 60; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/RunWorkflowFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/RunWorkflowFunction.cs index 889f2c3e..be838b6e 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/RunWorkflowFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/RunWorkflowFunction.cs @@ -13,7 +13,6 @@ public class RunWorkflowFunction : IBuiltinFunctionDefinition public string DisplayName => "Run Workflow"; public bool IsFlowControl => false; public bool IsNonExtractable => false; // Value-producing (has Return pin) — can be nested as an expression - public CFGStatementKind StatementKind => CFGStatementKind.Expression; public double NodeWidth => 140; public double NodeHeight => 60; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/SetFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/SetFunction.cs index 14ab179f..b349e357 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/SetFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/SetFunction.cs @@ -17,7 +17,6 @@ public class SetFunction : IBuiltinFunctionDefinition public string DisplayName => "Set"; public bool IsFlowControl => false; public bool IsNonExtractable => true; - public CFGStatementKind StatementKind => CFGStatementKind.Set; public double NodeWidth => 160; public double NodeHeight => 60; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/StartPluginFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/StartPluginFunction.cs index addca11c..74140465 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/StartPluginFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/StartPluginFunction.cs @@ -14,7 +14,6 @@ public class StartPluginFunction : IBuiltinFunctionDefinition public string DisplayName => "Start Plugin"; public bool IsFlowControl => false; public bool IsNonExtractable => true; - public CFGStatementKind StatementKind => CFGStatementKind.Expression; public double NodeWidth => 140; public double NodeHeight => 60; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/StopPluginFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/StopPluginFunction.cs index 61d8d115..8a569f36 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/StopPluginFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/StopPluginFunction.cs @@ -14,7 +14,6 @@ public class StopPluginFunction : IBuiltinFunctionDefinition public string DisplayName => "Stop Plugin"; public bool IsFlowControl => false; public bool IsNonExtractable => true; - public CFGStatementKind StatementKind => CFGStatementKind.Expression; public double NodeWidth => 140; public double NodeHeight => 60; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/StopWorkflowFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/StopWorkflowFunction.cs index 9ae34ae8..d08ad39d 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/StopWorkflowFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/StopWorkflowFunction.cs @@ -13,7 +13,6 @@ public class StopWorkflowFunction : IBuiltinFunctionDefinition public string DisplayName => "Stop Workflow"; public bool IsFlowControl => false; public bool IsNonExtractable => true; - public CFGStatementKind StatementKind => CFGStatementKind.Expression; public double NodeWidth => 140; public double NodeHeight => 60; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/StringConcatFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/StringConcatFunction.cs index 72436260..0972eb16 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/StringConcatFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/StringConcatFunction.cs @@ -18,7 +18,6 @@ public class StringConcatFunction : IBuiltinFunctionDefinition public string DisplayName => "String Concat"; public bool IsFlowControl => false; public bool IsNonExtractable => false; // value-producing, can be nested - public CFGStatementKind StatementKind => CFGStatementKind.Expression; public double NodeWidth => 140; public double NodeHeight => 70; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/SwitchFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/SwitchFunction.cs index 8b870b13..43e66320 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/SwitchFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/SwitchFunction.cs @@ -29,7 +29,7 @@ public class SwitchFunction : IBuiltinFunctionDefinition public bool IsFlowControl => true; public bool IsNonExtractable => false; public bool IsBlockTerminator => true; - public CFGStatementKind StatementKind => CFGStatementKind.Switch; + public FlowControlType? FlowControlShape => FlowControlType.IndexedDispatch; public double NodeWidth => 120; public double NodeHeight => 80; @@ -57,7 +57,7 @@ public class SwitchFunction : IBuiltinFunctionDefinition { LineNumber = lineNumber, SourceCode = exprText ?? invoke.ToFullString(), - ControlType = FlowControlType.Switch + ControlType = FlowControlType.IndexedDispatch }; if (args.Count >= 1) @@ -151,7 +151,7 @@ public List EmitStatements(CFGStatement stmt, CSEmitContext ctx var selector = helper.GetInputValue(node, Pins.Selector); var stmt = new FlowControlStatement { - ControlType = FlowControlType.Switch, + ControlType = FlowControlType.IndexedDispatch, ConditionExpression = selector, LineNumber = 1 }; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ToLoopCondFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ToLoopCondFunction.cs index e8baeb08..c602237d 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ToLoopCondFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ToLoopCondFunction.cs @@ -17,7 +17,7 @@ public class ToLoopCondFunction : IBuiltinFunctionDefinition public bool IsFlowControl => true; public bool IsNonExtractable => false; public bool IsBlockTerminator => true; - public CFGStatementKind StatementKind => CFGStatementKind.ToLoopCond; + public FlowControlType? FlowControlShape => FlowControlType.LoopBackedge; public double NodeWidth => 80; public double NodeHeight => 60; @@ -42,7 +42,7 @@ public class ToLoopCondFunction : IBuiltinFunctionDefinition { LineNumber = lineNumber, SourceCode = exprText ?? invoke.ToFullString(), - ControlType = FlowControlType.ToLoopCond + ControlType = FlowControlType.LoopBackedge }; if (args.Count >= 1) stmt.LoopbackTarget = ExprUtils.GetStringLiteralValue(args[0].Expression) ?? string.Empty; return stmt; @@ -86,7 +86,7 @@ public List EmitStatements(CFGStatement stmt, CSEmitContext ctx var returnTo = targetScope.Name; return new FlowControlStatement { - ControlType = FlowControlType.ToLoopCond, + ControlType = FlowControlType.LoopBackedge, LoopbackTarget = returnTo, SourceCode = $"NextBlock = ToLoopCond(\"{returnTo}\");", LineNumber = 1 @@ -98,7 +98,7 @@ public List EmitStatements(CFGStatement stmt, CSEmitContext ctx return new FlowControlStatement { - ControlType = FlowControlType.ToLoopCond, + ControlType = FlowControlType.LoopBackedge, SourceCode = "ToLoopCond();", LineNumber = 1 }; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/TryGetDeviceFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/TryGetDeviceFunction.cs index 0d09d824..19e689aa 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/TryGetDeviceFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/TryGetDeviceFunction.cs @@ -21,7 +21,6 @@ public class TryGetDeviceFunction : IBuiltinFunctionDefinition public string DisplayName => "TryGetDevice"; public bool IsFlowControl => false; public bool IsNonExtractable => false; - public CFGStatementKind StatementKind => CFGStatementKind.TryGetDevice; public double NodeWidth => 120; public double NodeHeight => 60; @@ -63,7 +62,7 @@ public List LowerToCFG( return [new CFGStatement { BlockName = blockName, - Kind = CFGStatementKind.TryGetDevice, + Kind = CFGStatementKind.Expression, FunctionName = FunctionName, PubVarTarget = pubVarTarget, Arguments = expandedArgs.ToList(), diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/WriteTextFileFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/WriteTextFileFunction.cs index cb6e6fd4..0898eb6a 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/WriteTextFileFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/WriteTextFileFunction.cs @@ -13,7 +13,6 @@ public class WriteTextFileFunction : IBuiltinFunctionDefinition public string DisplayName => "Write Text File"; public bool IsFlowControl => false; public bool IsNonExtractable => true; - public CFGStatementKind StatementKind => CFGStatementKind.Expression; public double NodeWidth => 160; public double NodeHeight => 80; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/CFG/BlueprintDebugContext.cs b/KitX Clients/KitX Workflow/KitX.Workflow/CFG/BlueprintDebugContext.cs deleted file mode 100644 index aea104ae..00000000 --- a/KitX Clients/KitX Workflow/KitX.Workflow/CFG/BlueprintDebugContext.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace KitX.Workflow.CFG; - -public class BlueprintDebugContext -{ - public Dictionary StatementToNodeId { get; } = new(); -} diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/CFG/CFGBlock.cs b/KitX Clients/KitX Workflow/KitX.Workflow/CFG/CFGBlock.cs index 66697ed5..aa5107a8 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/CFG/CFGBlock.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/CFG/CFGBlock.cs @@ -78,9 +78,8 @@ public class CFGBlock /// /// Whether this block ends with a control flow statement - /// (Branch, Loop, Switch, ToLoopCond, Break, Return). + /// (ConditionalJump, IterativeJump, IndexedDispatch, LoopBackedge, LoopExit, ScriptReturn). /// public bool EndsWithControlFlow => - Statements.Count > 0 && Statements[^1] is { Kind: CFGStatementKind.Branch or CFGStatementKind.Loop - or CFGStatementKind.Switch or CFGStatementKind.ToLoopCond or CFGStatementKind.Break }; + Statements.Count > 0 && Statements[^1].FlowControlShape != null; } \ No newline at end of file diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/CFG/CFGStatement.cs b/KitX Clients/KitX Workflow/KitX.Workflow/CFG/CFGStatement.cs index e894e4e9..49bc7932 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/CFG/CFGStatement.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/CFG/CFGStatement.cs @@ -3,41 +3,36 @@ namespace KitX.Workflow.CFG; using KitX.Core.Contract.Workflow; /// -/// Kinds of statements in the CFG. Unified statement kind replacing the former Pipeline.FormattedStatementKind. +/// Kinds of statements in the CFG — the VALUE-CARRYING classification only. +/// Control-flow shape is now carried by +/// (a ), queried directly by converters; the 5 control-flow +/// Kind values below (Branch/Loop/Switch/ToLoopCond/Break) remain only as derived labels +/// for diagnostics () and the derived +/// . The former per-builtin values +/// (Print/Set/Get/Pause/PluginCallWithTarget/TryGetDevice) were registry-migration leftovers +/// with zero consumers and have been removed. /// public enum CFGStatementKind { /// Unknown or unclassified Unknown, - /// Print(expr) - Print, - - /// Pause(ms) - Pause, - - /// Set("varName", expr) - Set, - - /// Get("varName") — standalone or as part of a PubVar assignment - Get, - - /// pubVar = FunctionCall(args...) + /// pubVar = FunctionCall(args...) — value assigned to a PubVar Assignment, - /// Branch(condition, trueBlock, falseBlock) + /// Conditional two-way jump (derived label; authoritative shape = FlowControlShape.ConditionalJump) Branch, - /// Loop(condition, loopBody, afterLoop) + /// Iterative jump with loop-back (derived label; authoritative shape = FlowControlShape.IterativeJump) Loop, - /// Switch(selector, defaultBlock, b0, b1, ...) — N-way dispatch by integer index + /// N-way dispatch (derived label; authoritative shape = FlowControlShape.IndexedDispatch) Switch, - /// ToLoopCond("parentBlock") + /// Loop back-edge (derived label; authoritative shape = FlowControlShape.LoopBackedge) ToLoopCond, - /// Break() + /// Loop exit (derived label; authoritative shape = FlowControlShape.LoopExit) Break, /// NextBlock = ... (handled internally, no node created) @@ -45,12 +40,6 @@ public enum CFGStatementKind /// Plain expression without assignment Expression, - - /// PluginCallWithTarget(pluginName, methodName, targetDevice, args...) — cross-device plugin call - PluginCallWithTarget, - - /// TryGetDevice(deviceSearchPattern) — returns DeviceInfo or null - TryGetDevice, } /// within a CFG block. All expressions are flat — @@ -76,6 +65,15 @@ public class CFGStatement /// public CFGStatementKind Kind { get; set; } + /// + /// The control-flow graph shape of this statement (null for non-control-flow statements). + /// This is the authoritative control-flow classification — consumers should query this + /// instead of switching on . Populated from the builtin descriptor's + /// FlowControlShape during lowering, or set directly for CFG-synthesized statements + /// (e.g. ToLoopCond → ). + /// + public FlowControlType? FlowControlShape { get; set; } + /// /// The original source expression for this statement. /// Used for BlockScript serialization and debugging. diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/CFG/ControlFlowGraph.cs b/KitX Clients/KitX Workflow/KitX.Workflow/CFG/ControlFlowGraph.cs index de84b2d1..4ca30224 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/CFG/ControlFlowGraph.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/CFG/ControlFlowGraph.cs @@ -46,7 +46,13 @@ public class ControlFlowGraph /// public int PubVarCounter { get; set; } = 1; - public BlueprintDebugContext? DebugContext { get; set; } + /// + /// Debug mapping from CFG statement IDs to Blueprint node IDs. + /// Populated during BP→CFG / CFG→BP conversion for use by the debug execution pipeline. + /// Null when no debug context was produced. (Inlined from the former BlueprintDebugContext + /// 1-field wrapper; the null guard distinguishes "no mapping" from "empty mapping".) + /// + public Dictionary? DebugStatementToNodeId { get; set; } /// /// Dumps the CFG as a human-readable string for diagnostics. diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Compilation/CFG2CSConverter.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Compilation/CFG2CSConverter.cs index 375f19cb..0af5b82d 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Compilation/CFG2CSConverter.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Compilation/CFG2CSConverter.cs @@ -20,8 +20,7 @@ namespace KitX.Workflow.Compilation; /// internal static class CFG2CSConverter { - private static readonly BuiltinFunctionRegistry FunctionRegistry = - BuiltinFunctionRegistry.Discover(typeof(CFG2CSConverter).Assembly); + private static readonly BuiltinFunctionRegistry FunctionRegistry = BuiltinFunctionRegistry.Instance; public static bool IsDebugMode { get; set; } @@ -93,8 +92,9 @@ internal static Dictionary InferPubVarTypes( { foreach (var stmt in block.Statements) { - // Branch/Loop condition demands bool - if ((stmt.Kind == CFGStatementKind.Branch || stmt.Kind == CFGStatementKind.Loop) + // Conditional/iterative jump condition demands bool + if ((stmt.FlowControlShape == FlowControlType.ConditionalJump + || stmt.FlowControlShape == FlowControlType.IterativeJump) && !string.IsNullOrEmpty(stmt.ConditionPubVar) && pubVarTypes.ContainsKey(stmt.ConditionPubVar)) { diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Compilation/CSCompiler.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Compilation/CSCompiler.cs index 6c955951..b1f93fc5 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Compilation/CSCompiler.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Compilation/CSCompiler.cs @@ -25,8 +25,7 @@ internal class CSCompiler /// Auto-discovered builtin function registry used by the /// to correctly classify and expand function calls during the formatting phase. /// - private static readonly BuiltinFunctionRegistry FunctionRegistry = - BuiltinFunctionRegistry.Discover(typeof(CSCompiler).Assembly); + private static readonly BuiltinFunctionRegistry FunctionRegistry = BuiltinFunctionRegistry.Instance; /// /// Cached compiled script entries, keyed by computed script hash. diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Compilation/CSEmitContext.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Compilation/CSEmitContext.cs index 6dcaed30..75472a20 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Compilation/CSEmitContext.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Compilation/CSEmitContext.cs @@ -44,6 +44,18 @@ public ExpressionSyntax ConvertTo(string typeName, ExpressionSyntax expr) public InvocationExpressionSyntax GInvoke(string member, params ExpressionSyntax[] args) => CFG2CSConverter.BuildGInvoke(member, args); + /// Builds a G.Get<T>("varName") typed-PubVar read expression. + public InvocationExpressionSyntax GetInvocation(string varName, string typeName) + => CFG2CSConverter.BuildGetInvocation(varName, typeName); + + /// Builds a G.PluginCall(...) expression for a dotted plugin-method call. + public InvocationExpressionSyntax PluginCallExpression(CFGStatement stmt) + => CFG2CSConverter.BuildPluginCallExpression(stmt, PubVarTypes); + + /// Builds a G.PluginCallWithTarget(...) expression for a cross-device call. + public InvocationExpressionSyntax PluginCallWithTargetExpression(CFGStatement stmt) + => CFG2CSConverter.BuildPluginCallWithTargetExpression(stmt, PubVarTypes); + /// Builds a G.member(args...) expression statement. public StatementSyntax GInvokeStatement(string member, params ExpressionSyntax[] args) => ExpressionStatement(GInvoke(member, args)); diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Compilation/ScriptCompilationBackend.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Compilation/ScriptCompilationBackend.cs index faa25ed6..0bc17cec 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Compilation/ScriptCompilationBackend.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Compilation/ScriptCompilationBackend.cs @@ -86,9 +86,9 @@ internal static List GetCompilationReferences() var seedAssemblies = new Assembly[] { typeof(BlockScriptExecutionGlobals).Assembly, - typeof(KitX.Workflow.Abstractions.Models.BlockScript).Assembly, + typeof(KitX.Workflow.Models.BlockScript).Assembly, typeof(ICompiledBlockScript).Assembly, - typeof(KitX.Workflow.Abstractions.Models.PluginCallInfo).Assembly, + typeof(KitX.Workflow.Models.PluginCallInfo).Assembly, typeof(Microsoft.CSharp.RuntimeBinder.Binder).Assembly, typeof(object).Assembly, typeof(System.Collections.Generic.List<>).Assembly, diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BP2CFGConverter.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BP2CFGConverter.cs index 262fc3fe..9717ceec 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BP2CFGConverter.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BP2CFGConverter.cs @@ -39,7 +39,7 @@ public ControlFlowGraph Build(KitX.Core.Contract.Workflow.Blueprint blueprint) { var cfg = new ControlFlowGraph { - DebugContext = new BlueprintDebugContext() + DebugStatementToNodeId = new Dictionary() }; // ── Step 1: Index and classify all nodes and connections ── @@ -125,7 +125,7 @@ public ControlFlowGraph Build(KitX.Core.Contract.Workflow.Blueprint blueprint) cfg.Blocks.Count, cfg.Blocks.Sum(b => b.Successors.Count)); // ── Step 9: Populate debug node mapping ── - if (cfg.DebugContext != null && cfg.Blocks != null) + if (cfg.DebugStatementToNodeId != null && cfg.Blocks != null) { foreach (var block in cfg.Blocks) { @@ -133,7 +133,7 @@ public ControlFlowGraph Build(KitX.Core.Contract.Workflow.Blueprint blueprint) foreach (var stmt in block.Statements) { if (stmt != null && !string.IsNullOrEmpty(stmt.StatementId)) - cfg.DebugContext.StatementToNodeId[stmt.StatementId] = stmt.StatementId; + cfg.DebugStatementToNodeId[stmt.StatementId] = stmt.StatementId; } } } @@ -378,7 +378,7 @@ private void WalkNode( if (isControlFlow) { pendingControlFlowNodes.Add(node); - if (stmt.Kind == CFGStatementKind.Loop) + if (stmt.FlowControlShape == FlowControlType.IterativeJump) { loopNodes[node.Id] = node; loopOwnerBlockNames[node.Id] = currentBlock.Name; @@ -386,8 +386,8 @@ private void WalkNode( } } - // If ToLoopCond, don't follow exec chain - if (stmt is { Kind: CFGStatementKind.ToLoopCond }) + // If LoopBackedge, don't follow exec chain + if (stmt is { FlowControlShape: FlowControlType.LoopBackedge }) return; if (isControlFlow) @@ -559,7 +559,7 @@ private void BuildEdgesFromTopology( { FromBlockName = block.Name, ToBlockName = arm.TargetBlockName, - Type = GetEdgeType(lastStmt.Kind, arm), + Type = GetEdgeType(lastStmt.FlowControlShape, arm), PinName = arm.PinName }); } @@ -567,7 +567,7 @@ private void BuildEdgesFromTopology( } // Non-registry control flow handling (fallback) - if (lastStmt.Kind == CFGStatementKind.ToLoopCond) + if (lastStmt.FlowControlShape == FlowControlType.LoopBackedge) { if (!string.IsNullOrEmpty(lastStmt.LoopbackTarget)) block.Successors.Add(new CFGEdge @@ -579,7 +579,7 @@ private void BuildEdgesFromTopology( continue; } - if (lastStmt.Kind == CFGStatementKind.Break) + if (lastStmt.FlowControlShape == FlowControlType.LoopExit) { block.Successors.Add(new CFGEdge { @@ -682,10 +682,10 @@ private static void ClassifyBlockTypes(ControlFlowGraph cfg) if (block.Statements.Count > 0) { var lastStmt = block.Statements[^1]; - block.Type = lastStmt.Kind switch + block.Type = lastStmt.FlowControlShape switch { - CFGStatementKind.Branch => CFGBlockType.BranchHeader, - CFGStatementKind.Loop => CFGBlockType.LoopHeader, + FlowControlType.ConditionalJump => CFGBlockType.BranchHeader, + FlowControlType.IterativeJump => CFGBlockType.LoopHeader, _ => block.Type }; } @@ -880,6 +880,7 @@ private CFGStatement ConvertBlockStatementToCfgStatement(BlockStatement blockStm { case FlowControlStatement flow: cfgStmt.Kind = ControlFlowMapping.ToKind(flow.ControlType); + cfgStmt.FlowControlShape = flow.ControlType; cfgStmt.FunctionName = ControlFlowMapping.ToFunctionName(flow.ControlType); if (string.IsNullOrEmpty(cfgStmt.FunctionName)) cfgStmt.FunctionName = null; @@ -944,7 +945,8 @@ private static CFGStatement CreateToLoopCondStatement(string? returnTo) { var stmt = new CFGStatement { - Kind = CFGStatementKind.ToLoopCond, + Kind = CFGStatementKind.Expression, + FlowControlShape = FlowControlType.LoopBackedge, OriginalExpression = returnTo != null ? $"NextBlock = ToLoopCond(\"{returnTo}\");" : "NextBlock = ToLoopCond();", @@ -1020,8 +1022,8 @@ private static void ResolveControlFlowTargets( { foreach (var stmt in block.Statements) { - if (stmt.Kind is not (CFGStatementKind.Branch or CFGStatementKind.Loop - or CFGStatementKind.Switch)) + if (stmt.FlowControlShape is not (FlowControlType.ConditionalJump + or FlowControlType.IterativeJump or FlowControlType.IndexedDispatch)) continue; if (stmt.Arms.Count > 0) continue; @@ -1109,10 +1111,10 @@ private static void ResolveControlFlowTargetsForNode( private static string RegenerateBranchSource(CFGStatement cfStmt) { - return cfStmt.Kind switch + return cfStmt.FlowControlShape switch { - CFGStatementKind.Loop => $"NextBlock = Loop({cfStmt.ConditionExpression}, \"{cfStmt.TrueBlockName}\", \"{cfStmt.FalseBlockName}\");", - CFGStatementKind.Switch => RegenerateSwitchSource(cfStmt), + FlowControlType.IterativeJump => $"NextBlock = Loop({cfStmt.ConditionExpression}, \"{cfStmt.TrueBlockName}\", \"{cfStmt.FalseBlockName}\");", + FlowControlType.IndexedDispatch => RegenerateSwitchSource(cfStmt), _ => $"NextBlock = Branch({cfStmt.ConditionExpression}, \"{cfStmt.TrueBlockName}\", \"{cfStmt.FalseBlockName}\");" }; } @@ -1128,18 +1130,18 @@ private static string RegenerateSwitchSource(CFGStatement cfStmt) return $"NextBlock = Switch({cfStmt.ConditionExpression}, \"{defaultBlock}\", {string.Join(", ", blocks)});"; } - private static CFGEdgeType GetEdgeType(CFGStatementKind kind, BranchArm arm) + private static CFGEdgeType GetEdgeType(FlowControlType? shape, BranchArm arm) { if (arm.IsLoopback) return CFGEdgeType.LoopbackToCondition; // Derive edge semantics from the pin name so the mapping is data-driven rather than - // positional. Handles Branch (True/False), Loop (LoopBody/LoopEnd) and Switch - // (Default/0/1/...) uniformly. - return (kind, arm.PinName) switch + // positional. Handles ConditionalJump (True/False), IterativeJump (LoopBody/LoopEnd) and + // IndexedDispatch (Default/0/1/...) uniformly. + return (shape, arm.PinName) switch { - (CFGStatementKind.Loop, "LoopBody") => CFGEdgeType.LoopBody, - (CFGStatementKind.Loop, "LoopEnd") => CFGEdgeType.LoopExit, - (CFGStatementKind.Switch, _) => CFGEdgeType.Switch, + (FlowControlType.IterativeJump, "LoopBody") => CFGEdgeType.LoopBody, + (FlowControlType.IterativeJump, "LoopEnd") => CFGEdgeType.LoopExit, + (FlowControlType.IndexedDispatch, _) => CFGEdgeType.Switch, (_, "False") => CFGEdgeType.BranchFalse, (_, "LoopEnd") => CFGEdgeType.LoopExit, _ => CFGEdgeType.BranchTrue diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BS2CFGConverter.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BS2CFGConverter.cs index 8a3562ee..11427002 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BS2CFGConverter.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BS2CFGConverter.cs @@ -135,7 +135,9 @@ private List FormatFlowControl(FlowControlStatement flowCtrl, stri // Determine the function name from the source code or control type var functionName = GetFunctionNameFromFlowControl(flowCtrl); - var kind = _functionRegistry?.Get(functionName)?.StatementKind ?? ControlFlowMapping.ToKind(flowCtrl.ControlType); + var def = _functionRegistry?.Get(functionName); + var kind = def?.StatementKind ?? ControlFlowMapping.ToKind(flowCtrl.ControlType); + var shape = def?.FlowControlShape ?? flowCtrl.ControlType; // Expand condition for Branch/Loop var hasCondition = !string.IsNullOrEmpty(flowCtrl.ConditionExpression); @@ -153,6 +155,7 @@ private List FormatFlowControl(FlowControlStatement flowCtrl, stri StatementId = !string.IsNullOrEmpty(flowCtrl.StatementId) ? flowCtrl.StatementId : Guid.NewGuid().ToString(), BlockName = blockName, Kind = kind, + FlowControlShape = shape, FunctionName = functionName, ConditionPubVar = condPubVar, ConditionExpression = flowCtrl.ConditionExpression, @@ -164,8 +167,8 @@ private List FormatFlowControl(FlowControlStatement flowCtrl, stri }; result.Add(stmt); - // Store loop condition for duplication before ToLoopCond - if (kind == CFGStatementKind.Loop && !string.IsNullOrEmpty(condPubVar)) + // Store loop condition for duplication before LoopBackedge + if (shape == FlowControlType.IterativeJump && !string.IsNullOrEmpty(condPubVar)) { context.LoopConditions[blockName] = new ConditionInfo { diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BlockScriptSerializer.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BlockScriptSerializer.cs index 416be0c9..8ea3bb78 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BlockScriptSerializer.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BlockScriptSerializer.cs @@ -8,9 +8,9 @@ namespace KitX.Workflow.Conversion; /// -/// Serializes a to source code string. -/// This is the deterministic BP→BS Phase 4 — pure string assembly, -/// no logic. +/// Serializes a model to source code string. +/// This is the final stage of CFG→BS conversion: builds the +/// BlockScript model tree, then this class renders it to text. Pure string assembly, no logic. /// /// NextBlock assignment is derived from /// using @@ -110,10 +110,10 @@ private static bool EndsWithControlFlow(BlockDefinition block) var last = block.Statements[^1]; if (last is FlowControlStatement flow) { - return flow.ControlType is FlowControlType.Branch - or FlowControlType.Loop - or FlowControlType.ToLoopCond - or FlowControlType.Break; + return flow.ControlType is FlowControlType.ConditionalJump + or FlowControlType.IterativeJump + or FlowControlType.LoopBackedge + or FlowControlType.LoopExit; } return false; } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BlueprintToBlockScriptConverter.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BlueprintToBlockScriptConverter.cs index c6bd0ce5..5b6efb60 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BlueprintToBlockScriptConverter.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BlueprintToBlockScriptConverter.cs @@ -18,7 +18,6 @@ public class BlueprintToBlockScriptConverter : IBlueprintToBlockScriptConverter // CFG pipeline components private readonly BP2CFGConverter _cfgBuilder; private readonly CFGConditionDuplicator _cfgConditionDuplicator = new(); - private readonly CFG2BSConverter _cfg2bs = new(); private readonly BlockScriptSerializer _serializer = new(); public BlueprintToBlockScriptConverter(IEnumerable definitions) @@ -73,8 +72,8 @@ public BlockScript ConvertToBlockScript(KitX.Core.Contract.Workflow.Blueprint bl script.HelperFunctions = blueprint.HelperFunctions ?? []; - if (cfg.DebugContext != null) - script.DebugNodeMapping = new Dictionary(cfg.DebugContext.StatementToNodeId); + if (cfg.DebugStatementToNodeId != null) + script.DebugNodeMapping = new Dictionary(cfg.DebugStatementToNodeId); Log.Debug("[BlueprintToScript] Done. Source code length: {Len}, HelperFunctions: {Count}, DebugMapping: {Map}", script.SourceCode?.Length ?? 0, script.HelperFunctions?.Count ?? 0, diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFG2BPConverter.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFG2BPConverter.cs index 5904b785..1adfc02c 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFG2BPConverter.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFG2BPConverter.cs @@ -49,10 +49,10 @@ public void Build(ControlFlowGraph script, PipelineContext context) ResolveCrossBlockEdges(context); // Populate debug context: StatementId → NodeId mapping - if (script.DebugContext != null) + if (script.DebugStatementToNodeId != null) { foreach (var kvp in context.NodeByStatementId) - script.DebugContext.StatementToNodeId[kvp.Key] = kvp.Value.Id; + script.DebugStatementToNodeId[kvp.Key] = kvp.Value.Id; } Log.Debug("[CFG2BPConverter] Done: {NodeCount} nodes, {ExecEdgeCount} exec edges", diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFG2BSConverter.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFG2BSConverter.cs index 4e6543a4..204ab1e7 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFG2BSConverter.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFG2BSConverter.cs @@ -111,13 +111,12 @@ public BlockScript Generate(ControlFlowGraph cfg) private static BlockStatement? ConvertStatement(CFGStatement cfgStmt) { // Control flow statements → FlowControlStatement - var controlType = ControlFlowMapping.ToControlType(cfgStmt.Kind); - if (controlType != null) + if (cfgStmt.FlowControlShape != null) { return new FlowControlStatement { StatementId = cfgStmt.StatementId, - ControlType = controlType.Value, + ControlType = cfgStmt.FlowControlShape.Value, ConditionExpression = cfgStmt.ConditionExpression ?? string.Empty, // Copy the full arm list so N-way Switch and any variadic shape survive. // ToLoopCond's loopback target lives in Arms[0] (IsLoopback=true), carried by this clone. diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFGConditionDuplicator.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFGConditionDuplicator.cs index 77c1b231..84edf59a 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFGConditionDuplicator.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFGConditionDuplicator.cs @@ -19,7 +19,7 @@ public void Duplicate(ControlFlowGraph cfg) foreach (var block in cfg.Blocks.Where(b => b.Type == CFGBlockType.LoopHeader)) { var loopStmt = block.Statements.FirstOrDefault(s => - s.Kind == CFGStatementKind.Loop); + s.FlowControlShape == FlowControlType.IterativeJump); if (loopStmt == null) continue; // Find the condition evaluation statements (everything before the Loop statement) @@ -27,7 +27,7 @@ public void Duplicate(ControlFlowGraph cfg) if (loopIndex <= 0) continue; var condStmts = block.Statements.Take(loopIndex) - .Where(s => s.Kind is CFGStatementKind.Assignment or CFGStatementKind.Get or CFGStatementKind.Expression) + .Where(s => s.Kind is CFGStatementKind.Assignment or CFGStatementKind.Expression) .ToList(); if (condStmts.Count == 0) continue; @@ -54,7 +54,7 @@ private static void InsertConditionDuplicates(CFGBlock bodyBlock, List(); return cfg; } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/ControlFlowMapping.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/ControlFlowMapping.cs index 63baf810..12262ec1 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/ControlFlowMapping.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/ControlFlowMapping.cs @@ -4,49 +4,49 @@ namespace KitX.Workflow.Conversion; /// -/// Centralised bidirectional mapping between the BlockScript layer's -/// and the CFG layer's , -/// plus the canonical function-name string for each control-flow kind. +/// Mapping helpers between the BlockScript layer's (the +/// authoritative control-flow shape, now carried on ) +/// and derived labels: the (diagnostic only) and the canonical +/// function-name string for each control-flow shape. /// -/// Replaces three duplicated mapping tables that had drifted across the converters: -/// BS2CFGConverter.MapControlTypeToKind / MapControlTypeToFunctionName, -/// BP2CFGConverter's inline switch inside ConvertBlockStatementToCfgStatement, and -/// CFG2BSConverter.KindToControlType. All five control-flow kinds -/// (Branch / Loop / Switch / ToLoopCond / Break) map 1:1; non-control-flow -/// CFGStatementKind values have no FlowControlType counterpart (null). +/// is the sole remaining string provider — control-flow consumers +/// should query stmt.FlowControlShape or def.FlowControlShape directly rather than +/// switching on Kind. / are retained as thin +/// bridges for the few sites that still read the derived Kind label (Dump output, +/// IBuiltinFunctionDefinition.StatementKind default). /// internal static class ControlFlowMapping { /// FlowControlType → CFGStatementKind. Unknown for unmapped. public static CFGStatementKind ToKind(FlowControlType type) => type switch { - FlowControlType.Branch => CFGStatementKind.Branch, - FlowControlType.Loop => CFGStatementKind.Loop, - FlowControlType.Switch => CFGStatementKind.Switch, - FlowControlType.ToLoopCond => CFGStatementKind.ToLoopCond, - FlowControlType.Break => CFGStatementKind.Break, + FlowControlType.ConditionalJump => CFGStatementKind.Branch, + FlowControlType.IterativeJump => CFGStatementKind.Loop, + FlowControlType.IndexedDispatch => CFGStatementKind.Switch, + FlowControlType.LoopBackedge => CFGStatementKind.ToLoopCond, + FlowControlType.LoopExit => CFGStatementKind.Break, _ => CFGStatementKind.Unknown }; /// FlowControlType → canonical function-name string (e.g. "Branch"). public static string ToFunctionName(FlowControlType type) => type switch { - FlowControlType.Branch => "Branch", - FlowControlType.Loop => "Loop", - FlowControlType.Switch => "Switch", - FlowControlType.ToLoopCond => "ToLoopCond", - FlowControlType.Break => "Break", + FlowControlType.ConditionalJump => "Branch", + FlowControlType.IterativeJump => "Loop", + FlowControlType.IndexedDispatch => "Switch", + FlowControlType.LoopBackedge => "ToLoopCond", + FlowControlType.LoopExit => "Break", _ => string.Empty }; /// CFGStatementKind → FlowControlType, or null for non-control-flow kinds. public static FlowControlType? ToControlType(CFGStatementKind kind) => kind switch { - CFGStatementKind.Branch => FlowControlType.Branch, - CFGStatementKind.Loop => FlowControlType.Loop, - CFGStatementKind.Switch => FlowControlType.Switch, - CFGStatementKind.ToLoopCond => FlowControlType.ToLoopCond, - CFGStatementKind.Break => FlowControlType.Break, + CFGStatementKind.Branch => FlowControlType.ConditionalJump, + CFGStatementKind.Loop => FlowControlType.IterativeJump, + CFGStatementKind.Switch => FlowControlType.IndexedDispatch, + CFGStatementKind.ToLoopCond => FlowControlType.LoopBackedge, + CFGStatementKind.Break => FlowControlType.LoopExit, _ => null }; } \ No newline at end of file diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/ExprUtils.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/ExprUtils.cs index a8679f4f..cc1ab99b 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/ExprUtils.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/ExprUtils.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; @@ -12,8 +13,21 @@ namespace KitX.Workflow.Conversion; /// public static class ExprUtils { - /// Parses an expression string using Roslyn. Returns null on failure. + /// + /// Memoization cache for — Roslyn parsing is the hot path + /// (called per-statement, often re-parsing the same OriginalExpression across phases). + /// Keyed by input expression string. + /// + private static readonly ConcurrentDictionary _parseExpressionCache = new(); + + /// Memoization cache for . + private static readonly ConcurrentDictionary _parseStatementCache = new(); + + /// Parses an expression string using Roslyn. Returns null on failure. Memoized. public static ExpressionSyntax? ParseExpression(string expression) + => _parseExpressionCache.GetOrAdd(expression, ParseExpressionCore); + + private static ExpressionSyntax? ParseExpressionCore(string expression) { try { @@ -29,8 +43,11 @@ public static class ExprUtils catch { return null; } } - /// Parses a full statement (may be assignment or plain expression). + /// Parses a full statement (may be assignment or plain expression). Memoized. public static (ExpressionSyntax? rightExpr, string? assignedVar)? ParseStatement(string statement) + => _parseStatementCache.GetOrAdd(statement, ParseStatementCore); + + private static (ExpressionSyntax? rightExpr, string? assignedVar)? ParseStatementCore(string statement) { try { @@ -138,9 +155,9 @@ public static bool IsCharacterLiteral(string value) if (string.IsNullOrEmpty(value)) return false; // Fast pre-check: C# char literals always start and end with single quote if (value.Length < 3 || value[0] != '\'' || value[^1] != '\'') return false; - // Validate with Roslyn - var expr = ParseExpression(value); - return expr is LiteralExpressionSyntax lit - && lit.Token.IsKind(SyntaxKind.CharacterLiteralToken); + // Validate with Roslyn — parse as a single token (far cheaper than a full expression + // tree parse). A char literal parses as a CharacterLiteralToken. + var token = SyntaxFactory.ParseToken(value); + return token.IsKind(SyntaxKind.CharacterLiteralToken); } } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/PipelineContext.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/PipelineContext.cs index 4476cd29..0a331e7e 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/PipelineContext.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/PipelineContext.cs @@ -7,8 +7,8 @@ namespace KitX.Workflow.Conversion; /// -/// Accumulated state flowing through all 6 conversion phases. -/// Replaces the old ConversionContext. +/// Accumulated mutable state flowing through all 6 phases of the BS→BP conversion pipeline. +/// (ConversionContext serves the opposite BP→CFG direction; the two coexist, not replace each other.) /// public class PipelineContext { diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/GlobalUsings.cs b/KitX Clients/KitX Workflow/KitX.Workflow/GlobalUsings.cs index 195cc089..4f88b144 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/GlobalUsings.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/GlobalUsings.cs @@ -5,9 +5,9 @@ global using KitX.Workflow.Hosting; global using KitX.Workflow.Abstractions; -global using KitX.Workflow.Abstractions.Models; -global using KitX.Workflow.Abstractions.Models.Statements; -global using KitX.Workflow.Abstractions.Models.Results; +global using KitX.Workflow.Models; +global using KitX.Workflow.Models.Statements; +global using KitX.Workflow.Models.Results; // Compilation / BlockScripting / Conversion are the three core subsystem namespaces and are // cross-referenced bidirectionally (e.g. Compilation's CSCompiler uses BlockScripting's // BuiltinFunctionRegistry and Conversion's CFGPipeline; BlockScripting's BlockScriptExecutor diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Hosting/ServiceCollectionExtensions.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Hosting/ServiceCollectionExtensions.cs index 812cc217..99faa15f 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Hosting/ServiceCollectionExtensions.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Hosting/ServiceCollectionExtensions.cs @@ -41,12 +41,12 @@ public static IServiceCollection AddKitXWorkflow(this IServiceCollection service var state = WorkflowScriptService.RuntimeState; var rpm = provider.GetRequiredService(); var executor = provider.GetRequiredService(); - var service = new BlockScriptServiceImpl(state, executor); + var service = new BlockScriptService(state, executor); Log.Information("[DI] IBlockScriptService created with RealPluginManager + wired executor. RPM HashCode: {HashCode}", rpm.GetHashCode()); return service; }); // IBlockScriptPipelineService was removed (zero interface-type consumers; - // WorkflowManagementService uses the concrete BlockScriptServiceImpl directly). + // WorkflowManagementService uses the concrete BlockScriptService directly). services.AddSingleton(sp => WorkflowScriptService.PluginServiceInstance); services.AddSingleton(sp => WorkflowScriptService.ManagementServiceInstance); diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/BlockDefinition.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Models/BlockDefinition.cs similarity index 96% rename from KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/BlockDefinition.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Models/BlockDefinition.cs index 7342ca32..a9bcc03f 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/BlockDefinition.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Models/BlockDefinition.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; -namespace KitX.Workflow.Abstractions.Models; +namespace KitX.Workflow.Models; /// /// Represents a single block definition in the script diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/BlockScript.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Models/BlockScript.cs similarity index 98% rename from KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/BlockScript.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Models/BlockScript.cs index 413ead04..ec9d8ca1 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/BlockScript.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Models/BlockScript.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; using KitX.Core.Contract.Workflow; -namespace KitX.Workflow.Abstractions.Models; +namespace KitX.Workflow.Models; /// /// Parsed block script container diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/BlockType.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Models/BlockType.cs similarity index 93% rename from KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/BlockType.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Models/BlockType.cs index 82a061b7..b52d5a86 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/BlockType.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Models/BlockType.cs @@ -1,4 +1,4 @@ -namespace KitX.Workflow.Abstractions.Models; +namespace KitX.Workflow.Models; /// /// Block type enumeration diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/ConversionDiag.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Models/ConversionDiag.cs similarity index 95% rename from KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/ConversionDiag.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Models/ConversionDiag.cs index 8ba02eca..0da44fed 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/ConversionDiag.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Models/ConversionDiag.cs @@ -1,4 +1,4 @@ -namespace KitX.Workflow.Abstractions.Models; +namespace KitX.Workflow.Models; /// /// Severity of a conversion/parse diagnostic. Mirrors the compile-path model so the diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/ConversionDiagnostics.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Models/ConversionDiagnostics.cs similarity index 98% rename from KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/ConversionDiagnostics.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Models/ConversionDiagnostics.cs index 89519b8b..7c97133e 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/ConversionDiagnostics.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Models/ConversionDiagnostics.cs @@ -2,7 +2,7 @@ using System.Linq; using System.Text; -namespace KitX.Workflow.Abstractions.Models; +namespace KitX.Workflow.Models; /// /// Collects user-facing diagnostics across the parse + conversion pipeline, mirroring the diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Models/FlowControlType.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Models/FlowControlType.cs new file mode 100644 index 00000000..c809a3b5 --- /dev/null +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Models/FlowControlType.cs @@ -0,0 +1,44 @@ +namespace KitX.Workflow.Models; + +/// +/// Control-flow graph shapes — the structural role a statement plays in the CFG, +/// independent of which builtin function produces it. Named by graph/edge semantics +/// (not by function name) to avoid confusion with IBuiltinFunctionDefinition.FunctionName. +/// +public enum FlowControlType +{ + /// + /// Conditional two-way jump (true/false arms). Produced by Branch (and Flip, which + /// reuses this shape). Graph: condition → {True arm, False arm}. + /// + ConditionalJump, + + /// + /// Iterative jump with a loop-back edge. Produced by Loop. Graph: condition → + /// {LoopBody arm (back-edge to condition), LoopExit arm}. + /// + IterativeJump, + + /// + /// Return from script execution (implicit control flow, no builtin). + /// + ScriptReturn, + + /// + /// Exit the enclosing loop. Produced by Break. Graph: unconditional jump to the + /// loop's after-block (synthesized __break__ target). + /// + LoopExit, + + /// + /// Loop back-edge — marks the end of a loop body and returns control to the loop + /// condition block. Produced by ToLoopCond. Graph: back-edge to LoopHeader. + /// + LoopBackedge, + + /// + /// N-way dispatch by integer index. Produced by Switch. Graph: selector → + /// {Default arm, 0, 1, ..., N-1 arms}. + /// + IndexedDispatch +} \ No newline at end of file diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/PluginCallInfo.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Models/PluginCallInfo.cs similarity index 97% rename from KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/PluginCallInfo.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Models/PluginCallInfo.cs index ba86e7a3..7bd376b7 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/PluginCallInfo.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Models/PluginCallInfo.cs @@ -1,6 +1,6 @@ using System; -namespace KitX.Workflow.Abstractions.Models; +namespace KitX.Workflow.Models; /// /// 插件调用信息,用于传递给 IPluginManager.Call 的参数。 diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/Results/BlockExecutionResult.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Models/Results/BlockExecutionResult.cs similarity index 95% rename from KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/Results/BlockExecutionResult.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Models/Results/BlockExecutionResult.cs index fe90c3a5..5bdeed54 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/Results/BlockExecutionResult.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Models/Results/BlockExecutionResult.cs @@ -1,4 +1,4 @@ -namespace KitX.Workflow.Abstractions.Models.Results; +namespace KitX.Workflow.Models.Results; /// /// Result of executing a block - used by state machine for flow control diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/Results/BlockScriptParseResult.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Models/Results/BlockScriptParseResult.cs similarity index 94% rename from KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/Results/BlockScriptParseResult.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Models/Results/BlockScriptParseResult.cs index c3bf6d0c..76a63168 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/Results/BlockScriptParseResult.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Models/Results/BlockScriptParseResult.cs @@ -1,4 +1,4 @@ -namespace KitX.Workflow.Abstractions.Models.Results; +namespace KitX.Workflow.Models.Results; /// /// Result of parsing operation diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/Statements/BlockStatement.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Models/Statements/BlockStatement.cs similarity index 92% rename from KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/Statements/BlockStatement.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Models/Statements/BlockStatement.cs index 0ca8ec76..e652b274 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/Statements/BlockStatement.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Models/Statements/BlockStatement.cs @@ -1,4 +1,4 @@ -namespace KitX.Workflow.Abstractions.Models.Statements; +namespace KitX.Workflow.Models.Statements; /// /// Base class for statements within a block diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/Statements/ExpressionStatement.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Models/Statements/ExpressionStatement.cs similarity index 95% rename from KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/Statements/ExpressionStatement.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Models/Statements/ExpressionStatement.cs index 8802f144..9c4427f3 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/Statements/ExpressionStatement.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Models/Statements/ExpressionStatement.cs @@ -1,6 +1,6 @@ using Microsoft.CodeAnalysis.CSharp.Syntax; -namespace KitX.Workflow.Abstractions.Models.Statements; +namespace KitX.Workflow.Models.Statements; /// /// Expression statement diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/Statements/FlowControlStatement.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Models/Statements/FlowControlStatement.cs similarity index 86% rename from KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/Statements/FlowControlStatement.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Models/Statements/FlowControlStatement.cs index a3bc5010..58e4be08 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/Statements/FlowControlStatement.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Models/Statements/FlowControlStatement.cs @@ -1,6 +1,6 @@ using KitX.Core.Contract.Workflow; -namespace KitX.Workflow.Abstractions.Models.Statements; +namespace KitX.Workflow.Models.Statements; /// /// Flow control statement @@ -76,13 +76,13 @@ public void RegenerateSourceCode() { SourceCode = ControlType switch { - FlowControlType.Branch => $"NextBlock = Branch({ConditionExpression}, \"{TrueBlockName}\", \"{FalseBlockName}\");", - FlowControlType.Loop => $"NextBlock = Loop({ConditionExpression}, \"{TrueBlockName}\", \"{FalseBlockName}\");", - FlowControlType.ToLoopCond => !string.IsNullOrEmpty(LoopbackTarget) + FlowControlType.ConditionalJump => $"NextBlock = Branch({ConditionExpression}, \"{TrueBlockName}\", \"{FalseBlockName}\");", + FlowControlType.IterativeJump => $"NextBlock = Loop({ConditionExpression}, \"{TrueBlockName}\", \"{FalseBlockName}\");", + FlowControlType.LoopBackedge => !string.IsNullOrEmpty(LoopbackTarget) ? $"NextBlock = ToLoopCond(\"{LoopbackTarget}\");" : "ToLoopCond();", - FlowControlType.Switch => RegenerateSwitchSource(), - FlowControlType.Break => "Break();", + FlowControlType.IndexedDispatch => RegenerateSwitchSource(), + FlowControlType.LoopExit => "Break();", _ => SourceCode }; } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/VariableDeclaration.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Models/VariableDeclaration.cs similarity index 93% rename from KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/VariableDeclaration.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Models/VariableDeclaration.cs index 973c782b..45386f02 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/Models/VariableDeclaration.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Models/VariableDeclaration.cs @@ -1,4 +1,4 @@ -namespace KitX.Workflow.Abstractions.Models; +namespace KitX.Workflow.Models; /// /// Variable declaration diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Services/BlockScriptServiceImpl.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Services/BlockScriptService.cs similarity index 95% rename from KitX Clients/KitX Workflow/KitX.Workflow/Services/BlockScriptServiceImpl.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Services/BlockScriptService.cs index 77610f29..608a9b35 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Services/BlockScriptServiceImpl.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Services/BlockScriptService.cs @@ -13,13 +13,13 @@ namespace KitX.Workflow.BlockScripting; /// WorkflowManagementService — the former IBlockScriptPipelineService indirection was removed /// as it had no interface-type consumers. /// -internal class BlockScriptServiceImpl : IBlockScriptService +internal class BlockScriptService : IBlockScriptService { private readonly WorkflowRuntimeState _state; private readonly BlockScriptExecutor _executor; /// - /// Initializes a new instance of BlockScriptServiceImpl. + /// Initializes a new instance of BlockScriptService. /// /// Shared runtime state. /// @@ -30,7 +30,7 @@ internal class BlockScriptServiceImpl : IBlockScriptService /// (instead of lazily constructing a bare new BlockScriptExecutor()) eliminates the /// prior dual-instance bug where the trigger execution path ran on an unwired executor. /// - internal BlockScriptServiceImpl(WorkflowRuntimeState state, IBlockScriptExecutor executor) + internal BlockScriptService(WorkflowRuntimeState state, IBlockScriptExecutor executor) { _state = state; // The contract guarantees a wired executor; cast once to the concrete type the @@ -93,7 +93,7 @@ public List ParseConstantsFromBlockScript(string sourceCode) } catch (Exception ex) { - Log.Warning(ex, "[BlockScriptServiceImpl] Error parsing constants from BlockScript"); + Log.Warning(ex, "[BlockScriptService] Error parsing constants from BlockScript"); } return result; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlueprintDebugger.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Services/BlueprintDebugger.cs similarity index 100% rename from KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlueprintDebugger.cs rename to KitX Clients/KitX Workflow/KitX.Workflow/Services/BlueprintDebugger.cs diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Services/WorkflowManagementService.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Services/WorkflowManagementService.cs index 2b56b799..69f56b01 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Services/WorkflowManagementService.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Services/WorkflowManagementService.cs @@ -13,14 +13,14 @@ internal class WorkflowManagementService : IWorkflowManagementService private readonly WorkflowRuntimeState _state; // Holds the concrete impl so both the public IBlockScriptService (source-string methods) // and the internal IBlockScriptPipelineService (parsed-model methods) are reachable. - private readonly BlockScriptServiceImpl _blockScriptService; + private readonly BlockScriptService _blockScriptService; /// /// Initializes a new instance of WorkflowManagementService. /// /// Shared runtime state. /// Block script execution service. - internal WorkflowManagementService(WorkflowRuntimeState state, BlockScriptServiceImpl blockScriptService) + internal WorkflowManagementService(WorkflowRuntimeState state, BlockScriptService blockScriptService) { _state = state; _blockScriptService = blockScriptService; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Services/WorkflowScriptService.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Services/WorkflowScriptService.cs index dac23101..c453ba93 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Services/WorkflowScriptService.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Services/WorkflowScriptService.cs @@ -13,7 +13,7 @@ namespace KitX.Workflow.Services; /// IWorkflowPluginService / IBlockScriptService / IBlockScriptPipelineService by forwarding /// to the real implementations. Those interface implementations were dead code — DI never /// resolved WorkflowScriptService itself as any of those interfaces (it registered -/// BlockScriptServiceImpl for IBlockScriptService, and the management/plugin services via the +/// BlockScriptService for IBlockScriptService, and the management/plugin services via the /// static factory properties below). The facade interface implementations have been removed; /// what remains is the static service-graph factory that the DI lambdas in /// ServiceCollectionExtensions still reference (RuntimeState / PluginServiceInstance / @@ -50,26 +50,26 @@ static WorkflowScriptService() /// /// Gets the ManagementService, creating it lazily once BlockScriptService is available. - /// BlockScriptServiceImpl is resolved from DI by the caller (ServiceCollectionExtensions), + /// BlockScriptService is resolved from DI by the caller (ServiceCollectionExtensions), /// but the management service here is built against the static SharedState + the - /// BlockScriptServiceImpl that DI constructs — wired via ManagementServiceInstance. + /// BlockScriptService that DI constructs — wired via ManagementServiceInstance. /// private static IWorkflowManagementService ManagementService => - _managementService ??= new WorkflowManagementService(SharedState, ResolveBlockScriptServiceImpl()); + _managementService ??= new WorkflowManagementService(SharedState, ResolveBlockScriptService()); /// Exposes IWorkflowManagementService for DI registration. internal static IWorkflowManagementService ManagementServiceInstance => ManagementService; /// - /// Resolves the DI-registered BlockScriptServiceImpl. The DI factory in + /// Resolves the DI-registered BlockScriptService. The DI factory in /// ServiceCollectionExtensions constructs it with RealPluginManager; here we fetch the - /// IBlockScriptService that DI registered (which is that same BlockScriptServiceImpl). + /// IBlockScriptService that DI registered (which is that same BlockScriptService). /// - private static BlockScriptServiceImpl ResolveBlockScriptServiceImpl() + private static BlockScriptService ResolveBlockScriptService() { - // The DI-registered IBlockScriptService is a BlockScriptServiceImpl instance; cast to + // The DI-registered IBlockScriptService is a BlockScriptService instance; cast to // reach the parsed-model methods that WorkflowManagementService needs. - var bss = (BlockScriptServiceImpl)ServiceLocator.GetRequiredService(); + var bss = (BlockScriptService)ServiceLocator.GetRequiredService(); return bss; } diff --git a/KitX.sln b/KitX.sln index 493201f4..d89dfff0 100644 --- a/KitX.sln +++ b/KitX.sln @@ -718,7 +718,6 @@ Global {78983ECE-5252-41C1-A8B3-6835FAF415F3}.Release|x86.ActiveCfg = Release|Any CPU {78983ECE-5252-41C1-A8B3-6835FAF415F3}.Release|x86.Build.0 = Release|Any CPU {DD575EF3-DF09-40A1-A89A-AB775D65A423}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {DD575EF3-DF09-40A1-A89A-AB775D65A423}.Debug|Any CPU.Build.0 = Debug|Any CPU {DD575EF3-DF09-40A1-A89A-AB775D65A423}.Debug|ARM.ActiveCfg = Debug|Any CPU {DD575EF3-DF09-40A1-A89A-AB775D65A423}.Debug|ARM.Build.0 = Debug|Any CPU {DD575EF3-DF09-40A1-A89A-AB775D65A423}.Debug|ARM64.ActiveCfg = Debug|Any CPU @@ -738,7 +737,6 @@ Global {DD575EF3-DF09-40A1-A89A-AB775D65A423}.Release|x86.ActiveCfg = Release|Any CPU {DD575EF3-DF09-40A1-A89A-AB775D65A423}.Release|x86.Build.0 = Release|Any CPU {4FF6F614-F88E-4621-9A30-F6A75873218D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {4FF6F614-F88E-4621-9A30-F6A75873218D}.Debug|Any CPU.Build.0 = Debug|Any CPU {4FF6F614-F88E-4621-9A30-F6A75873218D}.Debug|ARM.ActiveCfg = Debug|Any CPU {4FF6F614-F88E-4621-9A30-F6A75873218D}.Debug|ARM.Build.0 = Debug|Any CPU {4FF6F614-F88E-4621-9A30-F6A75873218D}.Debug|ARM64.ActiveCfg = Debug|Any CPU From f4ca4ad02f6a3566f7fd13a57683c018ff49a53c Mon Sep 17 00:00:00 2001 From: StarInk Date: Sun, 21 Jun 2026 14:29:07 +0200 Subject: [PATCH 108/110] =?UTF-8?q?=F0=9F=A7=A9=20Refactor(Workflow):=20BS?= =?UTF-8?q?=E4=BA=92=E8=AF=91=E5=B1=82=E5=89=A5=E7=A6=BBRoslyn=E6=B8=97?= =?UTF-8?q?=E9=80=8F,=E5=BC=95=E5=85=A5BSAST=E6=B6=88=E9=99=A4=E5=8F=8C?= =?UTF-8?q?=E9=87=8D=E8=A7=A3=E6=9E=90-=E4=BB=A3=E7=A0=81=E7=BB=84?= =?UTF-8?q?=E7=BB=87=E9=87=8D=E6=9E=84=E9=98=B6=E6=AE=B5L15?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 引入 KitX 自家的 BS 表达式 AST (Models/BSExpression.cs) 作为 Roslyn 与下游消费层的隔离边界。Roslyn 现仅在解析边界(BSExpressionAdapter.FromRoslyn/Parse)、helper 函数体校验与编译执行路径(CFG2CSConverter)使用——这些是它的正当职责;BS↔蓝图互译路径上的 AST 模型、内置函数接口与转换器全部改吃自家类型,不再接触 Roslyn。 消除的痛点: - P1 ExpressionStatement.ParsedInvocation (持有 InvocationExpressionSyntax) → ParsedExpression (BSExpression) - P2 ExprUtils.ParseExpression/ParseStatement 及 ConcurrentDictionary 双重解析缓存 → 整类删除 - P3 IBuiltinFunctionDefinition.ExtractStatement/LowerToCFG 签名 (InvocationExpressionSyntax) → BSCall - P4 BS2CFGConverter 双解析分支 → 统一读 ParsedExpression;GetFunctionNameFromFlowControl 简化为 ControlType 直查 - P5/P6 NodeExportHelper/DataEdgeBuilder 的 IsCharacterLiteral 改走 BSExpressionExtensions;三处手写 BS 源码重建收口到 FlowControlStatement.RenderSource 单一真相源 迁移面: 26 个内置函数 (21 个 ExtractStatement=>null 仅签名变;5 个真实现+2 个 LowerToCFG 机械替换);BP2CFGConverter.GenerateBlockStatement 构建 BSCall 以替代 ConvertBlockStatement 的 ParseStatement 回退。 全程不留任何兼容/回退路径(系统未发布)。验证: KitX.Workflow.Test --test all (A-W, 23 场景) 全绿,含往返保真(C/D/H/I/V)、编译执行断言 [0,1,2,Done](K/L)、嵌套控制流(W)。净减约 97 行。 --- .../IBuiltinFunctionDefinition.cs | 9 +- .../BlockScripting/BlockStatementExtractor.cs | 39 +-- .../Blueprint/NodeExportHelper.cs | 3 +- .../BuiltinFunctions/BranchFunction.cs | 13 +- .../BuiltinFunctions/BreakFunction.cs | 3 +- .../CreateWorkflowFunction.cs | 4 +- .../BuiltinFunctions/FlipFunction.cs | 11 +- .../BuiltinFunctions/GetFunction.cs | 7 +- .../GetPluginInfoByNameFunction.cs | 4 +- .../BuiltinFunctions/InstallPluginFunction.cs | 4 +- .../BuiltinFunctions/JsonGetFieldFunction.cs | 4 +- .../ListPluginNamesFunction.cs | 4 +- .../BuiltinFunctions/ListWorkflowsFunction.cs | 4 +- .../BuiltinFunctions/LoopFunction.cs | 13 +- .../BuiltinFunctions/PauseFunction.cs | 3 +- .../BuiltinFunctions/PluginCallFunction.cs | 3 +- .../PluginCallWithTargetFunction.cs | 3 +- .../BuiltinFunctions/PrintFunction.cs | 3 +- .../BuiltinFunctions/ReadTextFileFunction.cs | 4 +- .../BuiltinFunctions/RunWorkflowFunction.cs | 4 +- .../BuiltinFunctions/SetFunction.cs | 3 +- .../BuiltinFunctions/StartPluginFunction.cs | 4 +- .../BuiltinFunctions/StopPluginFunction.cs | 4 +- .../BuiltinFunctions/StopWorkflowFunction.cs | 4 +- .../BuiltinFunctions/StringConcatFunction.cs | 3 +- .../BuiltinFunctions/SwitchFunction.cs | 13 +- .../BuiltinFunctions/ToLoopCondFunction.cs | 9 +- .../BuiltinFunctions/TryGetDeviceFunction.cs | 7 +- .../BuiltinFunctions/WriteTextFileFunction.cs | 4 +- .../Conversion/BP2CFGConverter.cs | 85 ++--- .../Conversion/BS2CFGConverter.cs | 131 +++----- .../Conversion/DataEdgeBuilder.cs | 3 +- .../KitX.Workflow/Conversion/ExprUtils.cs | 129 +------- .../KitX.Workflow/Models/BSExpression.cs | 301 ++++++++++++++++++ .../Models/Statements/ExpressionStatement.cs | 25 +- .../Models/Statements/FlowControlStatement.cs | 55 +++- 36 files changed, 563 insertions(+), 359 deletions(-) create mode 100644 KitX Clients/KitX Workflow/KitX.Workflow/Models/BSExpression.cs diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/IBuiltinFunctionDefinition.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/IBuiltinFunctionDefinition.cs index 1fc34cea..48de857c 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/IBuiltinFunctionDefinition.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Abstractions/IBuiltinFunctionDefinition.cs @@ -2,6 +2,7 @@ using KitX.Core.Contract.Workflow; using KitX.Workflow.Conversion; using KitX.Workflow.CFG; +using KitX.Workflow.Models; namespace KitX.Workflow.BlockScripting; @@ -119,10 +120,10 @@ public interface IBuiltinFunctionDefinition // ─── 解析(BlockScript → AST)───────────────── /// - /// 从 Roslyn InvocationExpressionSyntax 提取语句。 + /// 从已解析的 (BS 表达式 AST)提取语句。 /// 返回 null 表示使用默认 ExpressionStatement 处理。 /// - BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText); + BlockStatement? ExtractStatement(BSCall invoke, int lineNumber, string? exprText); // ─── 格式化(AST → CFGStatement)───────── @@ -133,7 +134,7 @@ public interface IBuiltinFunctionDefinition /// BS2CFGConverter 对返回语句做横切后处理(StatementId/Fingerprint/PubVarNames 追踪)。 /// List LowerToCFG( - InvocationExpressionSyntax invoke, + BSCall invoke, IReadOnlyList expandedArgs, string blockName, PipelineContext context, @@ -148,7 +149,7 @@ List LowerToCFG( FunctionName = FunctionName, Arguments = expandedArgs.ToList(), PubVarTarget = assignedVar, - OriginalExpression = invoke.ToString(), + OriginalExpression = invoke.SourceText, SourceLine = 0, } }; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockStatementExtractor.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockStatementExtractor.cs index ae06778a..3308bb8c 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockStatementExtractor.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockStatementExtractor.cs @@ -6,6 +6,7 @@ using static KitX.Workflow.BlockScripting.BlockScriptWellKnown.Blocks; using KitX.Workflow.Conversion; +using KitX.Workflow.Models; namespace KitX.Workflow.BlockScripting; @@ -89,7 +90,7 @@ public BlockDefinition CreateBlockDefinition( // Pre-evaluate constant values if (variable.Initializer?.Value is LiteralExpressionSyntax literal) { - varDefinition.DefaultValue = ExprUtils.GetLiteralValue(literal); + varDefinition.DefaultValue = literal.Token.Value; } blockDef.Variables.Add(varDefinition); @@ -172,7 +173,7 @@ private static void AddLocalVarDeclarations(LocalDeclarationStatementSyntax varD if (variable.Initializer?.Value is LiteralExpressionSyntax literal) { - varDefinition.DefaultValue = ExprUtils.GetLiteralValue(literal); + varDefinition.DefaultValue = literal.Token.Value; } block.Variables.Add(varDefinition); @@ -184,8 +185,7 @@ private static void AddLocalVarDeclarations(LocalDeclarationStatementSyntax varD /// without invocation RHS), NextBlock string directive, or unsupported form. /// Uses early returns to keep nesting shallow. The registry-hit-returns-null and /// registry-miss cases share one construction path via - /// (they previously held two - /// character-identical new ExpressionStatement blocks). + /// . /// private void AddExpressionStatement( ExpressionStatementSyntax exprStmt, BlockDefinition block, @@ -217,9 +217,10 @@ private void AddExpressionStatement( Log.Debug("[BlockStatementExtractor] assignment.Right is NOT InvocationExpressionSyntax, type = {Type}", assignment.Right.GetType().Name); // Preserve AssignedVariable so BS2CFGConverter can handle non-invocation RHS - // (e.g. v = a + b + c where RHS is BinaryExpression). + // (e.g. v = a + b + c where RHS is BinaryExpression). Adapt the RHS once here so + // BS2CFGConverter walks the BS AST directly instead of re-parsing the text. block.Statements.Add(BuildExpressionStatement(exprStmt, exprText, - parsedInvocation: assignment.Right as InvocationExpressionSyntax, + parsedExpression: BSExpressionAdapter.FromRoslyn(assignment.Right), assignedVar: assignment.Left.ToString())); return; } @@ -235,24 +236,23 @@ private void AddExpressionStatement( Log.Debug("[BlockStatementExtractor] Unhandled expression type in {BlockType}: {Type} = {Expr}", blockType, exprStmt.Expression.GetType().Name, exprText); block.Statements.Add(BuildExpressionStatement(exprStmt, exprText, - parsedInvocation: null, assignedVar: null)); + parsedExpression: null, assignedVar: null)); } /// /// Handles an invocation expression statement (bare call or call assigned to a - /// variable). Consults the builtin registry first; if it produces a statement it - /// is used directly, otherwise a generic carries - /// the already-parsed invocation so BS2CFGConverter does not re-parse the same - /// expression text (eliminates the double parse). The registry-hit-returns-null - /// and registry-miss cases converge on . + /// variable). Adapts the Roslyn invocation to a once, then + /// consults the builtin registry; if it produces a statement it is used directly, + /// otherwise a generic carries the already-parsed + /// call so BS2CFGConverter does not re-parse the same expression text. /// private void AddInvocationStatement( ExpressionStatementSyntax exprStmt, InvocationExpressionSyntax invoke, string exprText, string? assignedVar, BlockDefinition block) { - var methodName = ExprUtils.GetMethodName(invoke); - var stmt = _functionRegistry?.Get(methodName)?.ExtractStatement(invoke, exprStmt.GetLineNumber(), exprText); - block.Statements.Add(stmt ?? BuildExpressionStatement(exprStmt, exprText, invoke, assignedVar)); + var bsCall = (BSCall)BSExpressionAdapter.FromRoslyn(invoke)!; + var stmt = _functionRegistry?.Get(bsCall.MethodName)?.ExtractStatement(bsCall, exprStmt.GetLineNumber(), exprText); + block.Statements.Add(stmt ?? BuildExpressionStatement(exprStmt, exprText, bsCall, assignedVar)); } /// @@ -276,17 +276,18 @@ private static bool TryHandleNextBlockStringAssign(AssignmentExpressionSyntax as /// /// Single factory for the generic fallback. - /// Replaces the six near-identical inline initializers (two pairs of which were - /// character-for-character identical: registry-hit-returns-null vs registry-miss). + /// is the BS AST adapted from the Roslyn node + /// (a for invocations, or a /other for + /// non-invocation assignment RHS). Carried so BS2CFGConverter skips re-parsing text. /// private static ExpressionStatement BuildExpressionStatement( ExpressionStatementSyntax exprStmt, string exprText, - InvocationExpressionSyntax? parsedInvocation, string? assignedVar) => new() + BSExpression? parsedExpression, string? assignedVar) => new() { LineNumber = exprStmt.GetLineNumber(), SourceCode = exprText, Expression = exprText, - ParsedInvocation = parsedInvocation, + ParsedExpression = parsedExpression, AssignedVariable = assignedVar }; } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Blueprint/NodeExportHelper.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Blueprint/NodeExportHelper.cs index 41f3719c..31a28e5f 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Blueprint/NodeExportHelper.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Blueprint/NodeExportHelper.cs @@ -1,5 +1,6 @@ using KitX.Core.Contract.Workflow; using KitX.Workflow.Conversion; +using KitX.Workflow.Models; using static KitX.Workflow.BlockScripting.BlockScriptWellKnown.Pins; namespace KitX.Workflow.Blueprint; @@ -80,7 +81,7 @@ public static string FormatLiteralValue(string value, ConversionContext? ctx = n if (value == null) return string.Empty; if (value.Length == 0) return "\"\""; // empty string literal if (value.StartsWith("\"")) return value; - if (ExprUtils.IsCharacterLiteral(value)) return value; // char literal — pass through + if (BSExpressionExtensions.IsCharacterLiteral(value)) return value; // char literal — pass through if (ctx != null && ctx.AllPubVars.Contains(value)) return value; if (int.TryParse(value, out _) || double.TryParse(value, out _)) return value; if (value == "true" || value == "false") return value; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/BranchFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/BranchFunction.cs index 76c8f3a3..280571ec 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/BranchFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/BranchFunction.cs @@ -3,6 +3,7 @@ using KitX.Workflow.Conversion; using KitX.Workflow.CFG; using KitX.Workflow.BlockScripting; +using KitX.Workflow.Models; namespace KitX.Workflow.BuiltinFunctions { @@ -30,18 +31,18 @@ public class BranchFunction : IBuiltinFunctionDefinition new("False", PinType.Execution, 50) ]; - public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) + public BlockStatement? ExtractStatement(BSCall invoke, int lineNumber, string? exprText) { - var args = invoke.ArgumentList.Arguments; + var args = invoke.Args; var stmt = new FlowControlStatement { LineNumber = lineNumber, - SourceCode = exprText ?? invoke.ToFullString(), + SourceCode = exprText ?? invoke.SourceText, ControlType = FlowControlType.ConditionalJump }; - if (args.Count >= 1) stmt.ConditionExpression = args[0].Expression.ToString(); - if (args.Count >= 2) stmt.TrueBlockName = ExprUtils.GetStringLiteralValue(args[1].Expression) ?? string.Empty; - if (args.Count >= 3) stmt.FalseBlockName = ExprUtils.GetStringLiteralValue(args[2].Expression) ?? string.Empty; + if (args.Count >= 1) stmt.ConditionExpression = args[0].SourceText; + if (args.Count >= 2) stmt.TrueBlockName = args[1].AsStringLiteral() ?? string.Empty; + if (args.Count >= 3) stmt.FalseBlockName = args[2].AsStringLiteral() ?? string.Empty; return stmt; } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/BreakFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/BreakFunction.cs index e94745db..ac7a9184 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/BreakFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/BreakFunction.cs @@ -3,6 +3,7 @@ using KitX.Workflow.Conversion; using KitX.Workflow.CFG; using KitX.Workflow.BlockScripting; +using KitX.Workflow.Models; namespace KitX.Workflow.BuiltinFunctions; @@ -27,7 +28,7 @@ public class BreakFunction : IBuiltinFunctionDefinition public IReadOnlyList OutputPins => []; - public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; + public BlockStatement? ExtractStatement(BSCall invoke, int lineNumber, string? exprText) => null; public List EmitStatements(CFGStatement stmt, CSEmitContext ctx) => new() { ctx.Return() }; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/CreateWorkflowFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/CreateWorkflowFunction.cs index aa14af4b..319c078b 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/CreateWorkflowFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/CreateWorkflowFunction.cs @@ -1,9 +1,9 @@ -using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; using Serilog; using KitX.Workflow.Conversion; using KitX.Workflow.CFG; using KitX.Workflow.BlockScripting; +using KitX.Workflow.Models; namespace KitX.Workflow.BuiltinFunctions { @@ -27,7 +27,7 @@ public class CreateWorkflowFunction : IBuiltinFunctionDefinition new("Return", PinType.String, 40) ]; - public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; + public BlockStatement? ExtractStatement(BSCall invoke, int lineNumber, string? exprText) => null; public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) { diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/FlipFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/FlipFunction.cs index 4eb3b1b0..5358a251 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/FlipFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/FlipFunction.cs @@ -3,6 +3,7 @@ using KitX.Workflow.Conversion; using KitX.Workflow.CFG; using KitX.Workflow.BlockScripting; +using KitX.Workflow.Models; namespace KitX.Workflow.BuiltinFunctions { @@ -34,17 +35,17 @@ public class FlipFunction : IBuiltinFunctionDefinition ]; - public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) + public BlockStatement? ExtractStatement(BSCall invoke, int lineNumber, string? exprText) { - var args = invoke.ArgumentList.Arguments; + var args = invoke.Args; var stmt = new FlowControlStatement { LineNumber = lineNumber, - SourceCode = exprText ?? invoke.ToFullString(), + SourceCode = exprText ?? invoke.SourceText, ControlType = FlowControlType.ConditionalJump // Reuse ConditionalJump shape for cross-block routing }; - if (args.Count >= 1) stmt.TrueBlockName = ExprUtils.GetStringLiteralValue(args[0].Expression) ?? string.Empty; - if (args.Count >= 2) stmt.FalseBlockName = ExprUtils.GetStringLiteralValue(args[1].Expression) ?? string.Empty; + if (args.Count >= 1) stmt.TrueBlockName = args[0].AsStringLiteral() ?? string.Empty; + if (args.Count >= 2) stmt.FalseBlockName = args[1].AsStringLiteral() ?? string.Empty; return stmt; } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/GetFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/GetFunction.cs index b8d8c085..6cfa045f 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/GetFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/GetFunction.cs @@ -3,6 +3,7 @@ using KitX.Workflow.Conversion; using KitX.Workflow.CFG; using KitX.Workflow.BlockScripting; +using KitX.Workflow.Models; namespace KitX.Workflow.BuiltinFunctions; @@ -32,7 +33,7 @@ public class GetFunction : IBuiltinFunctionDefinition new("Value", PinType.Any, 40) ]; - public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; + public BlockStatement? ExtractStatement(BSCall invoke, int lineNumber, string? exprText) => null; public List EmitStatements(CFGStatement stmt, CSEmitContext ctx) { @@ -45,7 +46,7 @@ public List EmitStatements(CFGStatement stmt, CSEmitContext ctx } public List LowerToCFG( - InvocationExpressionSyntax invoke, IReadOnlyList expandedArgs, + BSCall invoke, IReadOnlyList expandedArgs, string blockName, PipelineContext context, string? assignedVar) { // Auto-generate PubVar if not already assigned @@ -68,7 +69,7 @@ public List LowerToCFG( FunctionName = FunctionName, PubVarTarget = pubVarTarget, Arguments = expandedArgs.ToList(), - OriginalExpression = invoke.ToString(), + OriginalExpression = invoke.SourceText, SourceLine = 0, }]; } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/GetPluginInfoByNameFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/GetPluginInfoByNameFunction.cs index 0f402783..a6a50945 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/GetPluginInfoByNameFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/GetPluginInfoByNameFunction.cs @@ -1,11 +1,11 @@ using System.Text.Json; -using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Plugin; using KitX.Core.Contract.Workflow; using Serilog; using KitX.Workflow.Conversion; using KitX.Workflow.CFG; using KitX.Workflow.BlockScripting; +using KitX.Workflow.Models; namespace KitX.Workflow.BuiltinFunctions { @@ -28,7 +28,7 @@ public class GetPluginInfoByNameFunction : IBuiltinFunctionDefinition new("Return", PinType.String, 40) ]; - public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; + public BlockStatement? ExtractStatement(BSCall invoke, int lineNumber, string? exprText) => null; public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) { diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/InstallPluginFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/InstallPluginFunction.cs index 3c042372..70f3f4e8 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/InstallPluginFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/InstallPluginFunction.cs @@ -1,10 +1,10 @@ -using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Plugin; using KitX.Core.Contract.Workflow; using Serilog; using KitX.Workflow.Conversion; using KitX.Workflow.CFG; using KitX.Workflow.BlockScripting; +using KitX.Workflow.Models; namespace KitX.Workflow.BuiltinFunctions { @@ -27,7 +27,7 @@ public class InstallPluginFunction : IBuiltinFunctionDefinition new("Return", PinType.Boolean, 40) ]; - public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; + public BlockStatement? ExtractStatement(BSCall invoke, int lineNumber, string? exprText) => null; public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) => node; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/JsonGetFieldFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/JsonGetFieldFunction.cs index ebe2ea23..899594e6 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/JsonGetFieldFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/JsonGetFieldFunction.cs @@ -1,10 +1,10 @@ using System.Text.Json; -using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; using Serilog; using KitX.Workflow.Conversion; using KitX.Workflow.CFG; using KitX.Workflow.BlockScripting; +using KitX.Workflow.Models; namespace KitX.Workflow.BuiltinFunctions { @@ -28,7 +28,7 @@ public class JsonGetFieldFunction : IBuiltinFunctionDefinition new("Return", PinType.String, 40) ]; - public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; + public BlockStatement? ExtractStatement(BSCall invoke, int lineNumber, string? exprText) => null; public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) { diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ListPluginNamesFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ListPluginNamesFunction.cs index d03f7adf..4f533a68 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ListPluginNamesFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ListPluginNamesFunction.cs @@ -1,11 +1,11 @@ using System.Text.Json; -using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Plugin; using KitX.Core.Contract.Workflow; using Serilog; using KitX.Workflow.Conversion; using KitX.Workflow.CFG; using KitX.Workflow.BlockScripting; +using KitX.Workflow.Models; namespace KitX.Workflow.BuiltinFunctions { @@ -27,7 +27,7 @@ public class ListPluginNamesFunction : IBuiltinFunctionDefinition new("Return", PinType.String, 40) ]; - public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; + public BlockStatement? ExtractStatement(BSCall invoke, int lineNumber, string? exprText) => null; public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) => node; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ListWorkflowsFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ListWorkflowsFunction.cs index d153e74a..75ace95e 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ListWorkflowsFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ListWorkflowsFunction.cs @@ -1,10 +1,10 @@ using System.Text.Json; -using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; using Serilog; using KitX.Workflow.Conversion; using KitX.Workflow.CFG; using KitX.Workflow.BlockScripting; +using KitX.Workflow.Models; namespace KitX.Workflow.BuiltinFunctions { @@ -26,7 +26,7 @@ public class ListWorkflowsFunction : IBuiltinFunctionDefinition new("Return", PinType.String, 40) ]; - public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; + public BlockStatement? ExtractStatement(BSCall invoke, int lineNumber, string? exprText) => null; public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) => node; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/LoopFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/LoopFunction.cs index f8a4a5d2..4d9eecaa 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/LoopFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/LoopFunction.cs @@ -3,6 +3,7 @@ using KitX.Workflow.Conversion; using KitX.Workflow.CFG; using KitX.Workflow.BlockScripting; +using KitX.Workflow.Models; namespace KitX.Workflow.BuiltinFunctions { @@ -31,18 +32,18 @@ public class LoopFunction : IBuiltinFunctionDefinition new("LoopEnd", PinType.Execution, 50) ]; - public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) + public BlockStatement? ExtractStatement(BSCall invoke, int lineNumber, string? exprText) { - var args = invoke.ArgumentList.Arguments; + var args = invoke.Args; var stmt = new FlowControlStatement { LineNumber = lineNumber, - SourceCode = exprText ?? invoke.ToFullString(), + SourceCode = exprText ?? invoke.SourceText, ControlType = FlowControlType.IterativeJump }; - if (args.Count >= 1) stmt.ConditionExpression = args[0].Expression.ToString(); - if (args.Count >= 2) stmt.TrueBlockName = ExprUtils.GetStringLiteralValue(args[1].Expression) ?? string.Empty; - if (args.Count >= 3) stmt.FalseBlockName = ExprUtils.GetStringLiteralValue(args[2].Expression) ?? string.Empty; + if (args.Count >= 1) stmt.ConditionExpression = args[0].SourceText; + if (args.Count >= 2) stmt.TrueBlockName = args[1].AsStringLiteral() ?? string.Empty; + if (args.Count >= 3) stmt.FalseBlockName = args[2].AsStringLiteral() ?? string.Empty; return stmt; } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PauseFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PauseFunction.cs index 3858b89d..c2e1ce35 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PauseFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PauseFunction.cs @@ -3,6 +3,7 @@ using KitX.Workflow.Conversion; using KitX.Workflow.CFG; using KitX.Workflow.BlockScripting; +using KitX.Workflow.Models; namespace KitX.Workflow.BuiltinFunctions { @@ -27,7 +28,7 @@ public class PauseFunction : IBuiltinFunctionDefinition new("Exec", PinType.Execution, 25) ]; - public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; + public BlockStatement? ExtractStatement(BSCall invoke, int lineNumber, string? exprText) => null; public List EmitStatements(CFGStatement stmt, CSEmitContext ctx) { diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PluginCallFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PluginCallFunction.cs index fe5cb1a8..1f9eb58e 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PluginCallFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PluginCallFunction.cs @@ -7,6 +7,7 @@ using KitX.Workflow.Blueprint; using KitX.Workflow.Services; using static KitX.Workflow.BlockScripting.BlockScriptWellKnown.Pins; +using KitX.Workflow.Models; namespace KitX.Workflow.BuiltinFunctions { @@ -40,7 +41,7 @@ public class PluginCallFunction : IBuiltinFunctionDefinition new("Return", PinType.Any, 40) ]; - public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; + public BlockStatement? ExtractStatement(BSCall invoke, int lineNumber, string? exprText) => null; public List EmitStatements(CFGStatement stmt, CSEmitContext ctx) => ctx.EmitValueAssignment(stmt.PubVarTarget, ctx.PluginCallExpression(stmt)); diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PluginCallWithTargetFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PluginCallWithTargetFunction.cs index bca907b6..6c1e60f1 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PluginCallWithTargetFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PluginCallWithTargetFunction.cs @@ -5,6 +5,7 @@ using KitX.Workflow.CFG; using KitX.Workflow.BlockScripting; using KitX.Workflow.Services; +using KitX.Workflow.Models; namespace KitX.Workflow.BuiltinFunctions { @@ -37,7 +38,7 @@ public class PluginCallWithTargetFunction : IBuiltinFunctionDefinition new("Return", PinType.Any, 40) ]; - public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; + public BlockStatement? ExtractStatement(BSCall invoke, int lineNumber, string? exprText) => null; public List EmitStatements(CFGStatement stmt, CSEmitContext ctx) => ctx.EmitValueAssignment(stmt.PubVarTarget, ctx.PluginCallWithTargetExpression(stmt)); diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PrintFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PrintFunction.cs index 5f49665c..373f24a6 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PrintFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/PrintFunction.cs @@ -3,6 +3,7 @@ using KitX.Workflow.Conversion; using KitX.Workflow.CFG; using KitX.Workflow.BlockScripting; +using KitX.Workflow.Models; using KitX.Workflow.Services; namespace KitX.Workflow.BuiltinFunctions @@ -28,7 +29,7 @@ public class PrintFunction : IBuiltinFunctionDefinition new("Exec", PinType.Execution, 25) ]; - public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; + public BlockStatement? ExtractStatement(BSCall invoke, int lineNumber, string? exprText) => null; public List EmitStatements(CFGStatement stmt, CSEmitContext ctx) { diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ReadTextFileFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ReadTextFileFunction.cs index 2c2429ac..0ceea8be 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ReadTextFileFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ReadTextFileFunction.cs @@ -1,9 +1,9 @@ -using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; using Serilog; using KitX.Workflow.Conversion; using KitX.Workflow.CFG; using KitX.Workflow.BlockScripting; +using KitX.Workflow.Models; namespace KitX.Workflow.BuiltinFunctions { @@ -26,7 +26,7 @@ public class ReadTextFileFunction : IBuiltinFunctionDefinition new("Return", PinType.String, 40) ]; - public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; + public BlockStatement? ExtractStatement(BSCall invoke, int lineNumber, string? exprText) => null; public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) => node; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/RunWorkflowFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/RunWorkflowFunction.cs index be838b6e..599df0d7 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/RunWorkflowFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/RunWorkflowFunction.cs @@ -1,9 +1,9 @@ -using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; using Serilog; using KitX.Workflow.Conversion; using KitX.Workflow.CFG; using KitX.Workflow.BlockScripting; +using KitX.Workflow.Models; namespace KitX.Workflow.BuiltinFunctions { @@ -26,7 +26,7 @@ public class RunWorkflowFunction : IBuiltinFunctionDefinition new("Return", PinType.Boolean, 40) ]; - public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; + public BlockStatement? ExtractStatement(BSCall invoke, int lineNumber, string? exprText) => null; public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) => node; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/SetFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/SetFunction.cs index b349e357..713341ae 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/SetFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/SetFunction.cs @@ -3,6 +3,7 @@ using KitX.Workflow.Conversion; using KitX.Workflow.CFG; using KitX.Workflow.BlockScripting; +using KitX.Workflow.Models; namespace KitX.Workflow.BuiltinFunctions { @@ -30,7 +31,7 @@ public class SetFunction : IBuiltinFunctionDefinition new("Exec", PinType.Execution, 20) ]; - public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; + public BlockStatement? ExtractStatement(BSCall invoke, int lineNumber, string? exprText) => null; public List EmitStatements(CFGStatement stmt, CSEmitContext ctx) { diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/StartPluginFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/StartPluginFunction.cs index 74140465..db4e9933 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/StartPluginFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/StartPluginFunction.cs @@ -1,10 +1,10 @@ -using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Plugin; using KitX.Core.Contract.Workflow; using Serilog; using KitX.Workflow.Conversion; using KitX.Workflow.CFG; using KitX.Workflow.BlockScripting; +using KitX.Workflow.Models; namespace KitX.Workflow.BuiltinFunctions { @@ -27,7 +27,7 @@ public class StartPluginFunction : IBuiltinFunctionDefinition new("Return", PinType.Boolean, 40) ]; - public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; + public BlockStatement? ExtractStatement(BSCall invoke, int lineNumber, string? exprText) => null; public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) => node; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/StopPluginFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/StopPluginFunction.cs index 8a569f36..ee6c7697 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/StopPluginFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/StopPluginFunction.cs @@ -1,10 +1,10 @@ -using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Plugin; using KitX.Core.Contract.Workflow; using Serilog; using KitX.Workflow.Conversion; using KitX.Workflow.CFG; using KitX.Workflow.BlockScripting; +using KitX.Workflow.Models; namespace KitX.Workflow.BuiltinFunctions { @@ -27,7 +27,7 @@ public class StopPluginFunction : IBuiltinFunctionDefinition new("Return", PinType.Boolean, 40) ]; - public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; + public BlockStatement? ExtractStatement(BSCall invoke, int lineNumber, string? exprText) => null; public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) => node; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/StopWorkflowFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/StopWorkflowFunction.cs index d08ad39d..c2eee762 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/StopWorkflowFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/StopWorkflowFunction.cs @@ -1,9 +1,9 @@ -using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; using Serilog; using KitX.Workflow.Conversion; using KitX.Workflow.CFG; using KitX.Workflow.BlockScripting; +using KitX.Workflow.Models; namespace KitX.Workflow.BuiltinFunctions { @@ -26,7 +26,7 @@ public class StopWorkflowFunction : IBuiltinFunctionDefinition new("Return", PinType.Boolean, 40) ]; - public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; + public BlockStatement? ExtractStatement(BSCall invoke, int lineNumber, string? exprText) => null; public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) => node; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/StringConcatFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/StringConcatFunction.cs index 0972eb16..a097fe93 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/StringConcatFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/StringConcatFunction.cs @@ -3,6 +3,7 @@ using KitX.Workflow.Conversion; using KitX.Workflow.CFG; using KitX.Workflow.BlockScripting; +using KitX.Workflow.Models; namespace KitX.Workflow.BuiltinFunctions; @@ -39,7 +40,7 @@ public class StringConcatFunction : IBuiltinFunctionDefinition /// public VariadicPinSpec? InputVariadic => new("Input ", 3, PinType.String); - public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; + public BlockStatement? ExtractStatement(BSCall invoke, int lineNumber, string? exprText) => null; public List EmitStatements(CFGStatement stmt, CSEmitContext ctx) { diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/SwitchFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/SwitchFunction.cs index 43e66320..1e15101b 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/SwitchFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/SwitchFunction.cs @@ -3,6 +3,7 @@ using KitX.Workflow.Conversion; using KitX.Workflow.CFG; using KitX.Workflow.BlockScripting; +using KitX.Workflow.Models; namespace KitX.Workflow.BuiltinFunctions { @@ -50,23 +51,23 @@ public class SwitchFunction : IBuiltinFunctionDefinition /// BS 解析:从 NextBlock = Switch(selector, "default", "b0", "b1", ...) 提取。 /// arg[0]=default,arg[1..N]=分支块。Arms = [Default, 0, 1, ..., N-1]。 /// - public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) + public BlockStatement? ExtractStatement(BSCall invoke, int lineNumber, string? exprText) { - var args = invoke.ArgumentList.Arguments; + var args = invoke.Args; var stmt = new FlowControlStatement { LineNumber = lineNumber, - SourceCode = exprText ?? invoke.ToFullString(), + SourceCode = exprText ?? invoke.SourceText, ControlType = FlowControlType.IndexedDispatch }; if (args.Count >= 1) - stmt.ConditionExpression = args[0].Expression.ToString(); + stmt.ConditionExpression = args[0].SourceText; // arg[1] = default block; arg[2..N] = branch blocks b0, b1, ... if (args.Count >= 2) { - var defaultBlock = ExprUtils.GetStringLiteralValue(args[1].Expression) ?? string.Empty; + var defaultBlock = args[1].AsStringLiteral() ?? string.Empty; stmt.Arms.Add(new BranchArm { PinName = Pins.Default, TargetBlockName = defaultBlock }); } else @@ -76,7 +77,7 @@ public class SwitchFunction : IBuiltinFunctionDefinition for (int i = 2; i < args.Count; i++) { - var block = ExprUtils.GetStringLiteralValue(args[i].Expression) ?? string.Empty; + var block = args[i].AsStringLiteral() ?? string.Empty; stmt.Arms.Add(new BranchArm { PinName = (i - 2).ToString(), TargetBlockName = block }); } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ToLoopCondFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ToLoopCondFunction.cs index c602237d..473f3641 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ToLoopCondFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/ToLoopCondFunction.cs @@ -3,6 +3,7 @@ using KitX.Workflow.Conversion; using KitX.Workflow.CFG; using KitX.Workflow.BlockScripting; +using KitX.Workflow.Models; namespace KitX.Workflow.BuiltinFunctions { @@ -35,16 +36,16 @@ public class ToLoopCondFunction : IBuiltinFunctionDefinition /// - public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) + public BlockStatement? ExtractStatement(BSCall invoke, int lineNumber, string? exprText) { - var args = invoke.ArgumentList.Arguments; + var args = invoke.Args; var stmt = new FlowControlStatement { LineNumber = lineNumber, - SourceCode = exprText ?? invoke.ToFullString(), + SourceCode = exprText ?? invoke.SourceText, ControlType = FlowControlType.LoopBackedge }; - if (args.Count >= 1) stmt.LoopbackTarget = ExprUtils.GetStringLiteralValue(args[0].Expression) ?? string.Empty; + if (args.Count >= 1) stmt.LoopbackTarget = args[0].AsStringLiteral() ?? string.Empty; return stmt; } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/TryGetDeviceFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/TryGetDeviceFunction.cs index 19e689aa..c1894902 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/TryGetDeviceFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/TryGetDeviceFunction.cs @@ -6,6 +6,7 @@ using KitX.Workflow.Conversion; using KitX.Workflow.CFG; using KitX.Workflow.BlockScripting; +using KitX.Workflow.Models; namespace KitX.Workflow.BuiltinFunctions { @@ -34,7 +35,7 @@ public class TryGetDeviceFunction : IBuiltinFunctionDefinition new("DeviceInfo", PinType.Any, 40) ]; - public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; + public BlockStatement? ExtractStatement(BSCall invoke, int lineNumber, string? exprText) => null; public List EmitStatements(CFGStatement stmt, CSEmitContext ctx) { @@ -44,7 +45,7 @@ public List EmitStatements(CFGStatement stmt, CSEmitContext ctx } public List LowerToCFG( - InvocationExpressionSyntax invoke, IReadOnlyList expandedArgs, + BSCall invoke, IReadOnlyList expandedArgs, string blockName, PipelineContext context, string? assignedVar) { string? pubVarTarget; @@ -66,7 +67,7 @@ public List LowerToCFG( FunctionName = FunctionName, PubVarTarget = pubVarTarget, Arguments = expandedArgs.ToList(), - OriginalExpression = invoke.ToString(), + OriginalExpression = invoke.SourceText, SourceLine = 0, }]; } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/WriteTextFileFunction.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/WriteTextFileFunction.cs index 0898eb6a..b73cb8a5 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/WriteTextFileFunction.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BuiltinFunctions/WriteTextFileFunction.cs @@ -1,9 +1,9 @@ -using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; using Serilog; using KitX.Workflow.Conversion; using KitX.Workflow.CFG; using KitX.Workflow.BlockScripting; +using KitX.Workflow.Models; namespace KitX.Workflow.BuiltinFunctions { @@ -26,7 +26,7 @@ public class WriteTextFileFunction : IBuiltinFunctionDefinition new("Exec", PinType.Execution, 20) ]; - public BlockStatement? ExtractStatement(InvocationExpressionSyntax invoke, int lineNumber, string? exprText) => null; + public BlockStatement? ExtractStatement(BSCall invoke, int lineNumber, string? exprText) => null; public BlueprintNode ConfigureNode(BlueprintNode node, CFGStatement stmt) => node; diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BP2CFGConverter.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BP2CFGConverter.cs index 9717ceec..25c793c5 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BP2CFGConverter.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BP2CFGConverter.cs @@ -1,6 +1,5 @@ using KitX.Core.Contract.Workflow; using KitX.Workflow.Blueprint; -using Microsoft.CodeAnalysis.CSharp.Syntax; using Serilog; using static KitX.Workflow.BlockScripting.BlockScriptWellKnown.Pins; @@ -8,6 +7,7 @@ using KitX.Workflow.CFG; using KitX.Workflow.BlockScripting; +using KitX.Workflow.Models; namespace KitX.Workflow.Conversion; /// @@ -764,6 +764,8 @@ private static void SetParentLoopReferences(ControlFlowGraph cfg) if (node is not CallNode call) return null; var callArgs = _exportHelper.GetInputArgs(call); string sourceCode; + string methodName; + string fullMethodName; if (!string.IsNullOrEmpty(call.TargetDevice)) { // 跨设备调用:PluginCallWithTarget("plugin", "method", "device", args...) @@ -776,6 +778,8 @@ private static void SetParentLoopReferences(ControlFlowGraph cfg) arg => NodeExportHelper.FormatLiteralValue(arg, _currentCtx))) : ""; sourceCode = $"PluginCallWithTarget({pluginNameLit}, {methodNameLit}, {targetDeviceLit}{extraArgs})"; + methodName = "PluginCallWithTarget"; + fullMethodName = "PluginCallWithTarget"; } else { @@ -783,13 +787,10 @@ private static void SetParentLoopReferences(ControlFlowGraph cfg) var funcRef = string.IsNullOrEmpty(call.PluginName) ? call.FunctionName : $"{call.PluginName}.{call.FunctionName}"; sourceCode = $"{funcRef}({callArgs})"; + methodName = call.FunctionName; + fullMethodName = funcRef; } - var callStmt = new ExpressionStatement - { - Expression = sourceCode, - SourceCode = sourceCode + ";", - LineNumber = 1 - }; + var callStmt = MakeCallStatement(sourceCode, methodName, fullMethodName); PostProcessCallReturn(node, callStmt); return callStmt; } @@ -798,12 +799,7 @@ private static void SetParentLoopReferences(ControlFlowGraph cfg) if (node is not CallHelperNode callHelper) return null; var helperArgs = _exportHelper.GetInputArgs(callHelper); var expression = $"{callHelper.HelperFunctionName}({helperArgs})"; - var helperStmt = new ExpressionStatement - { - Expression = expression, - SourceCode = expression + ";", - LineNumber = 1 - }; + var helperStmt = MakeCallStatement(expression, callHelper.HelperFunctionName, callHelper.HelperFunctionName); PostProcessCallReturn(node, helperStmt); return helperStmt; } @@ -833,6 +829,27 @@ private static void SetParentLoopReferences(ControlFlowGraph cfg) } } + /// + /// Builds an for a blueprint call, attaching a + /// so downstream + /// reads FunctionName directly from the AST instead of re-parsing SourceCode. + /// Args are not modeled structurally here (the CFG consumes argument strings from input pins); + /// only MethodName/FullMethodName/SourceText are needed. + /// + private static ExpressionStatement MakeCallStatement(string sourceCode, string methodName, string fullMethodName) + => new() + { + Expression = sourceCode, + SourceCode = sourceCode + ";", + LineNumber = 1, + ParsedExpression = new BSCall + { + MethodName = methodName, + FullMethodName = fullMethodName, + SourceText = sourceCode + } + }; + private void PostProcessCallReturn(BlueprintNode node, BlockStatement stmt) { if (_currentCtx == null) return; @@ -905,10 +922,10 @@ private CFGStatement ConvertBlockStatementToCfgStatement(BlockStatement blockStm else { cfgStmt.Kind = CFGStatementKind.Expression; - // Fallback: parse FunctionName from SourceCode for nodes without strategy (CallHelper, Call, etc.) - var fallbackParsed = ExprUtils.ParseStatement(expr.SourceCode); - if (fallbackParsed?.rightExpr is InvocationExpressionSyntax fallbackInvoke) - cfgStmt.FunctionName = ExprUtils.GetMethodName(fallbackInvoke); + // FunctionName from the pre-built BS call attached by GenerateBlockStatement + // (Call/CallHelper nodes carry a BSCall in ParsedExpression). + if (expr.ParsedExpression is BSCall bsCall) + cfgStmt.FunctionName = bsCall.MethodName; } // Arguments from node input pins (no text parsing) @@ -996,7 +1013,13 @@ private static CFGStatement CreateToLoopCondStatement(string? returnTo) { Expression = expr, SourceCode = sourceCode, - LineNumber = 1 + LineNumber = 1, + ParsedExpression = new BSCall + { + MethodName = functionName, + FullMethodName = functionName, + SourceText = expr + } }; } @@ -1110,25 +1133,13 @@ private static void ResolveControlFlowTargetsForNode( } private static string RegenerateBranchSource(CFGStatement cfStmt) - { - return cfStmt.FlowControlShape switch - { - FlowControlType.IterativeJump => $"NextBlock = Loop({cfStmt.ConditionExpression}, \"{cfStmt.TrueBlockName}\", \"{cfStmt.FalseBlockName}\");", - FlowControlType.IndexedDispatch => RegenerateSwitchSource(cfStmt), - _ => $"NextBlock = Branch({cfStmt.ConditionExpression}, \"{cfStmt.TrueBlockName}\", \"{cfStmt.FalseBlockName}\");" - }; - } - - /// - /// Regenerates Switch source from its arms. Arms layout: [Default, 0, 1, ..., N-1]. - /// - private static string RegenerateSwitchSource(CFGStatement cfStmt) - { - if (cfStmt.Arms.Count == 0) return $"NextBlock = Switch({cfStmt.ConditionExpression}, \"\");"; - var defaultBlock = cfStmt.Arms[0].TargetBlockName; - var blocks = cfStmt.Arms.Skip(1).Select(a => $"\"{a.TargetBlockName}\""); - return $"NextBlock = Switch({cfStmt.ConditionExpression}, \"{defaultBlock}\", {string.Join(", ", blocks)});"; - } + // Delegate to the shared renderer on FlowControlStatement so BS↔graph source text stays + // in sync with the instance RegenerateSourceCode path (single source of truth). + => FlowControlStatement.RenderSource( + cfStmt.FlowControlShape ?? FlowControlType.ConditionalJump, + cfStmt.ConditionExpression ?? string.Empty, + cfStmt.Arms, + loopbackTarget: null); private static CFGEdgeType GetEdgeType(FlowControlType? shape, BranchArm arm) { diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BS2CFGConverter.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BS2CFGConverter.cs index 11427002..f11f1153 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BS2CFGConverter.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BS2CFGConverter.cs @@ -1,9 +1,9 @@ -using Microsoft.CodeAnalysis.CSharp.Syntax; using KitX.Core.Contract.Workflow; using KitX.Workflow.BlockScripting; using Serilog; using KitX.Workflow.CFG; +using KitX.Workflow.Models; using KitX.Workflow.Blueprint; namespace KitX.Workflow.Conversion; @@ -182,17 +182,10 @@ private List FormatFlowControl(FlowControlStatement flowCtrl, stri } private static string GetFunctionNameFromFlowControl(FlowControlStatement flowCtrl) - { - var src = flowCtrl.SourceCode; - // Parse function name from source like "NextBlock = Branch(...)", "Break()", etc. - var parsed = ExprUtils.ParseStatement(src); - if (parsed?.rightExpr is InvocationExpressionSyntax invoke) - return ExprUtils.GetMethodName(invoke); - if (parsed?.rightExpr is IdentifierNameSyntax id) - return id.Identifier.Text; - // Fallback: derive from ControlType - return ControlFlowMapping.ToFunctionName(flowCtrl.ControlType); - } + // The ControlType is authoritative for flow-control statements — it was set by the + // builtin's ExtractStatement at parse time and uniquely maps to the function name. + // No need to re-parse SourceCode. + => ControlFlowMapping.ToFunctionName(flowCtrl.ControlType); // ────────────────────────────────────────────── // Expression statement formatting @@ -201,43 +194,18 @@ private static string GetFunctionNameFromFlowControl(FlowControlStatement flowCt private List FormatExpressionStatement(ExpressionStatement exprStmt, string blockName, PipelineContext context) { var result = new List(); - var expression = exprStmt.Expression; - - // Prefer the invocation that BlockStatementExtractor already parsed and attached, avoiding - // a second Roslyn parse of the same expression text (the double-parse smell). Fall back to - // parsing Expression when ParsedInvocation is absent (e.g. programmatically-built statements - // produced by CFG2BSConverter from a CFG, which have no source tree attached). - ExpressionSyntax? rightExpr; - string? assignedVar; - if (exprStmt.ParsedInvocation is { } preParsed) + + // The BS AST is attached by BlockStatementExtractor at parse time. Every ExpressionStatement + // reaching BS2CFG comes from parsed source, so ParsedExpression is always present. + if (exprStmt.ParsedExpression is not { } rightExpr) { - rightExpr = preParsed; - assignedVar = exprStmt.AssignedVariable; + context.Diagnostics.AddError("BS_UNPARSEABLE_STMT", + $"Statement in block '{blockName}' has no pre-parsed expression: {exprStmt.Expression}", + exprStmt.LineNumber > 0 ? exprStmt.LineNumber : null); + return result; } - else - { - var parsed = ExprUtils.ParseStatement(expression); - if (parsed == null) - { - context.Diagnostics.AddError("BS_UNPARSEABLE_STMT", - $"Could not parse statement in block '{blockName}': {expression}", - exprStmt.LineNumber > 0 ? exprStmt.LineNumber : null); - return result; - } - - var (r, a) = parsed.Value; - - if (r == null) - { - context.Diagnostics.AddError("BS_UNPARSEABLE_STMT", - $"Statement in block '{blockName}' has no right-hand expression: {expression}", - exprStmt.LineNumber > 0 ? exprStmt.LineNumber : null); - return result; - } - rightExpr = r; - assignedVar = a; - } + var assignedVar = exprStmt.AssignedVariable; // Handle "NextBlock = ..." assignments (should be handled as FlowControl by parser) if (assignedVar != null && assignedVar == "NextBlock") @@ -248,7 +216,7 @@ private List FormatExpressionStatement(ExpressionStatement exprStm // if there's an assignment target (v = "a" + "b"), redirect that statement's // PubVarTarget to v so we get a single clean CFG statement instead of a // vaaa#### temp + a separate v = vaaa#### assignment. - if (rightExpr is BinaryExpressionSyntax binExpr && binExpr.OperatorToken.Text == "+") + if (rightExpr is BSBinary binExpr && binExpr.Operator == "+") { var (expStmts, finalExpr) = ExpandExpression(rightExpr, blockName, context); @@ -277,13 +245,13 @@ private List FormatExpressionStatement(ExpressionStatement exprStm } // If RHS is a function invocation, process it - if (rightExpr is InvocationExpressionSyntax invoke) + if (rightExpr is BSCall invoke) { - var funcName = ExprUtils.GetMethodName(invoke); + var funcName = invoke.MethodName; if (string.IsNullOrEmpty(funcName)) { context.Diagnostics.AddError("BS_EMPTY_FUNCNAME", - $"Invocation in block '{blockName}' has no resolvable function name: {expression}", + $"Invocation in block '{blockName}' has no resolvable function name: {exprStmt.Expression}", exprStmt.LineNumber > 0 ? exprStmt.LineNumber : null); return result; } @@ -292,7 +260,7 @@ private List FormatExpressionStatement(ExpressionStatement exprStm if (_functionRegistry != null && _functionRegistry.Get(funcName) is { } fcDef && fcDef.IsFlowControl) return result; - var fullFuncName = ExprUtils.GetFullMethodName(invoke); + var fullFuncName = invoke.FullMethodName; result.AddRange(LowerAndPostProcess(invoke, funcName, blockName, context, assignedVar, fullFuncName, statementId: exprStmt.StatementId)); return result; @@ -306,12 +274,10 @@ private List FormatExpressionStatement(ExpressionStatement exprStm /// Formats a function invocation, expanding nested calls in arguments. /// private List LowerAndPostProcess( - InvocationExpressionSyntax invoke, string funcName, string blockName, + BSCall invoke, string funcName, string blockName, PipelineContext context, string? assignedVar, string? fullFuncName = null, string? statementId = null) { - // "_" is the dummy LHS produced by ExprUtils.ParseStatement for standalone - // expression statements (`_ = WriteTextFile(...)`); treat it as no assignment. if (assignedVar == "_") assignedVar = null; var result = new List(); @@ -350,7 +316,7 @@ private List LowerAndPostProcess( FullFunctionName = fullFuncName, PubVarTarget = pubVarTarget, Arguments = currentArgExprs, - OriginalExpression = invoke.ToString(), + OriginalExpression = invoke.SourceText, SourceLine = 0, }]; } @@ -391,14 +357,14 @@ private List LowerAndPostProcess( /// Returns (expansionStatements, currentArgStrings). /// private (List stmts, List argExprs) ExpandArguments( - InvocationExpressionSyntax invoke, string blockName, PipelineContext context) + BSCall invoke, string blockName, PipelineContext context) { var stmts = new List(); var argExprs = new List(); - foreach (var arg in invoke.ArgumentList.Arguments) + foreach (var arg in invoke.Args) { - var (expanded, finalExpr) = ExpandExpression(arg.Expression, blockName, context); + var (expanded, finalExpr) = ExpandExpression(arg, blockName, context); stmts.AddRange(expanded); argExprs.Add(finalExpr); } @@ -411,27 +377,27 @@ private List LowerAndPostProcess( /// Returns (expansionStatements, finalExpressionString). /// private (List stmts, string finalExpr) ExpandExpression( - ExpressionSyntax expr, string blockName, PipelineContext context) + BSExpression expr, string blockName, PipelineContext context) { // Literal → return as-is - if (expr is LiteralExpressionSyntax) - return (new(), expr.ToString()); + if (expr is BSLiteral) + return (new(), expr.SourceText); // Simple identifier → return as-is - if (expr is IdentifierNameSyntax) - return (new(), expr.ToString()); + if (expr is BSIdentifier) + return (new(), expr.SourceText); // Invocation → may need expansion - if (expr is InvocationExpressionSyntax invoke) + if (expr is BSCall invoke) { - var funcName = ExprUtils.GetMethodName(invoke); - var fullFuncName = ExprUtils.GetFullMethodName(invoke); + var funcName = invoke.MethodName; + var fullFuncName = invoke.FullMethodName; // Non-extractable / flow-control functions stay inline (cannot be nested-call results). if (_functionRegistry != null && _functionRegistry.Get(funcName) is { } inlineDef && (inlineDef.IsNonExtractable || inlineDef.IsFlowControl)) { - return (new(), invoke.ToString()); + return (new(), invoke.SourceText); } // Expand this call's arguments once (nested calls → PubVars), shared by both paths below. @@ -466,7 +432,7 @@ private List LowerAndPostProcess( FunctionName = funcName, FullFunctionName = fullFuncName, Arguments = expandedArgs, - OriginalExpression = $"{pubVarName} = {invoke}", + OriginalExpression = $"{pubVarName} = {invoke.SourceText}", Fingerprint = ExprUtils.ComputeFingerprint(funcName, expandedArgs) }); @@ -474,20 +440,20 @@ private List LowerAndPostProcess( } // Parenthesized expression - if (expr is ParenthesizedExpressionSyntax paren) - return ExpandExpression(paren.Expression, blockName, context); + if (expr is BSParenthesized paren) + return ExpandExpression(paren.Inner, blockName, context); // Binary expression (e.g. "prefix" + Get("var") + "suffix"). // For the "+" operator, collect all operands (left-associative chaining) and // synthesize a single StringConcat(...) call so the CFG→BP path produces a // proper blueprint node instead of an opaque string expression. - if (expr is BinaryExpressionSyntax binary) + if (expr is BSBinary binary) { - var op = binary.OperatorToken.Text; + var op = binary.Operator; if (op == "+") { // Flatten left-associative + chains: ((a + b) + c) → [a, b, c] - var operands = new List(); + var operands = new List(); CollectAddOperands(binary, operands); // Expand each operand (nested calls → temp PubVars), collect statements. @@ -530,7 +496,7 @@ private List LowerAndPostProcess( } // Default: return as-is - return (new(), expr.ToString()); + return (new(), expr.SourceText); } /// @@ -538,10 +504,9 @@ private List LowerAndPostProcess( /// flat list of leaf operands. e.g. ((a + b) + c) → [a, b, c]. /// Non-+ leaves (literals, identifiers, calls) are collected as-is. /// - private static void CollectAddOperands(BinaryExpressionSyntax binary, List operands) + private static void CollectAddOperands(BSBinary binary, List operands) { - if (binary.Left is BinaryExpressionSyntax leftBinary - && leftBinary.OperatorToken.Text == "+") + if (binary.Left is BSBinary leftBinary && leftBinary.Operator == "+") { CollectAddOperands(leftBinary, operands); } @@ -575,22 +540,22 @@ private static void CollectAddOperands(BinaryExpressionSyntax binary, List -/// Shared expression-parsing utilities used across pipeline phases. +/// Parser-agnostic BS/CFG helpers. The Roslyn-coupled expression-parsing helpers that used +/// to live here (ParseExpression / ParseStatement / GetStringLiteralValue / GetLiteralValue / +/// GetMethodName / GetFullMethodName / IsCharacterLiteral) have been retired: BS now has its +/// own AST () adapted from Roslyn exactly once +/// at the parse boundary, so consumers walk the structured AST instead of re-parsing text. +/// What remains here are pure string/counter utilities with no Roslyn dependency. /// public static class ExprUtils { - /// - /// Memoization cache for — Roslyn parsing is the hot path - /// (called per-statement, often re-parsing the same OriginalExpression across phases). - /// Keyed by input expression string. - /// - private static readonly ConcurrentDictionary _parseExpressionCache = new(); - - /// Memoization cache for . - private static readonly ConcurrentDictionary _parseStatementCache = new(); - - /// Parses an expression string using Roslyn. Returns null on failure. Memoized. - public static ExpressionSyntax? ParseExpression(string expression) - => _parseExpressionCache.GetOrAdd(expression, ParseExpressionCore); - - private static ExpressionSyntax? ParseExpressionCore(string expression) - { - try - { - var wrappedCode = "_ = " + expression + ";"; - var syntaxTree = CSharpSyntaxTree.ParseText(wrappedCode, cancellationToken: CancellationToken.None); - var root = syntaxTree.GetCompilationUnitRoot(); - var globalStmt = root.Members.FirstOrDefault() as GlobalStatementSyntax; - var stmt = globalStmt?.Statement as ExpressionStatementSyntax; - if (stmt?.Expression is AssignmentExpressionSyntax assignment) - return assignment.Right; - return stmt?.Expression; - } - catch { return null; } - } - - /// Parses a full statement (may be assignment or plain expression). Memoized. - public static (ExpressionSyntax? rightExpr, string? assignedVar)? ParseStatement(string statement) - => _parseStatementCache.GetOrAdd(statement, ParseStatementCore); - - private static (ExpressionSyntax? rightExpr, string? assignedVar)? ParseStatementCore(string statement) - { - try - { - var wrappedCode = "_ = " + statement + ";"; - var syntaxTree = CSharpSyntaxTree.ParseText(wrappedCode, cancellationToken: CancellationToken.None); - var root = syntaxTree.GetCompilationUnitRoot(); - var globalStmt = root.Members.FirstOrDefault() as GlobalStatementSyntax; - var stmt = globalStmt?.Statement as ExpressionStatementSyntax; - if (stmt?.Expression == null) return (null, null); - - if (stmt.Expression is AssignmentExpressionSyntax outerAssignment) - { - ExpressionSyntax rightExpr = outerAssignment.Right; - AssignmentExpressionSyntax? innermost = null; - while (rightExpr is AssignmentExpressionSyntax nested) - { - innermost = nested; - rightExpr = nested.Right; - } - var assignedVar = (innermost ?? outerAssignment).Left.ToString().Trim(); - return (rightExpr, assignedVar); - } - return (stmt.Expression, null); - } - catch { return (null, null); } - } - - /// Extracts the short method name from an invocation expression. - public static string GetMethodName(InvocationExpressionSyntax invoke) - { - if (invoke.Expression is IdentifierNameSyntax id) return id.Identifier.Text; - if (invoke.Expression is GenericNameSyntax generic) return generic.Identifier.Text; - if (invoke.Expression is MemberAccessExpressionSyntax member) return member.Name.Identifier.Text; - return string.Empty; - } - - /// - /// Extracts the full dotted method path from an invocation expression. - /// For "TestPlugin.WPF.Core.HelloKitX()" returns "TestPlugin.WPF.Core.HelloKitX". - /// For simple calls like "Get(...)" returns just "Get". - /// - public static string GetFullMethodName(InvocationExpressionSyntax invoke) - { - if (invoke.Expression is IdentifierNameSyntax id) return id.Identifier.Text; - if (invoke.Expression is GenericNameSyntax generic) return generic.Identifier.Text; - if (invoke.Expression is MemberAccessExpressionSyntax member) - return member.Expression.ToString() + "." + member.Name.Identifier.Text; - return string.Empty; - } - /// /// Generates a PubVar name from a linear counter, cycling from vaaa0001 to vzzz9999. /// Format: 'v' + 3 lowercase letters + 4 digits. Total capacity: 26^3 * 10000 = 175,760,000. @@ -128,36 +42,7 @@ public static string GeneratePubVarName(int counter) return letterPart * 10000 + digitPart; } - /// /// Computes a fingerprint string for a call expression for reuse detection. public static string ComputeFingerprint(string funcName, List args) => $"{funcName}({string.Join(",", args.Select(a => a.Trim().Replace(" ", "")))})"; - - /// Gets the string value of a string literal, or null. - public static string? GetStringLiteralValue(ExpressionSyntax expr) - { - if (expr is LiteralExpressionSyntax lit && lit.Token.IsKind(SyntaxKind.StringLiteralToken)) - return lit.Token.ValueText; - return null; - } - - /// Gets the value of any literal expression (string, int, double, bool, null). - public static object? GetLiteralValue(LiteralExpressionSyntax literal) - => literal.Token.Value; - - /// - /// Determines whether a value string represents a C# character literal (e.g. '\0', 'a', '\n'). - /// Uses Roslyn parsing for reliable detection — avoids string-pattern heuristics. - /// Returns true only when the expression parses as a CharacterLiteralExpression. - /// - public static bool IsCharacterLiteral(string value) - { - if (string.IsNullOrEmpty(value)) return false; - // Fast pre-check: C# char literals always start and end with single quote - if (value.Length < 3 || value[0] != '\'' || value[^1] != '\'') return false; - // Validate with Roslyn — parse as a single token (far cheaper than a full expression - // tree parse). A char literal parses as a CharacterLiteralToken. - var token = SyntaxFactory.ParseToken(value); - return token.IsKind(SyntaxKind.CharacterLiteralToken); - } } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Models/BSExpression.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Models/BSExpression.cs new file mode 100644 index 00000000..fef06ea7 --- /dev/null +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Models/BSExpression.cs @@ -0,0 +1,301 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace KitX.Workflow.Models; + +// ───────────────────────────────────────────────────────────────────────────── +// BSExpression — KitX's own AST for BlockScript expressions. +// +// Why this exists: BS's block-interior is a restricted C# subset, and the +// previous implementation let Roslyn's SyntaxNode types (InvocationExpressionSyntax, +// BinaryExpressionSyntax, …) leak through the AST model +// (ExpressionStatement.ParsedInvocation), the builtin-function interface +// (IBuiltinFunctionDefinition.ExtractStatement/LowerToCFG), and the converters +// (BS2CFGConverter walked Roslyn trees directly). That leakage forced downstream +// code to re-parse expression strings (ExprUtils.ParseExpression/ParseStatement, +// each backed by a ConcurrentDictionary cache) — the "double parse" smell. +// +// BSExpression is the single bridge: Roslyn is used exactly once, at the parse +// boundary in BSExpressionAdapter.FromRoslyn, to build these parser-agnostic +// nodes. Everything downstream consumes BSExpression and never touches Roslyn. +// +// Roslyn retains its legitimate duties: BlockSyntaxValidator (syntax checking + +// diagnostics), helper-function bodies (full C#), and the compilation backend +// (CFG2CSConverter). It is no longer involved in AST representation or any +// BS↔Blueprint translation step. +// ───────────────────────────────────────────────────────────────────────────── + +/// +/// Root of the BlockScript expression AST. Every node remembers the exact source +/// text it was built from (lossless round-trip for SourceCode fields). +/// +public abstract class BSExpression +{ + /// Verbatim source text of this expression (e.g. Get(currentLoop)). + public string SourceText { get; set; } = string.Empty; +} + +/// +/// A literal value: string, integer, boolean, float/double, char, or null. +/// Carries the typed so consumers never re-parse the text. +/// +public sealed class BSLiteral : BSExpression +{ + public BSLiteralKind Kind { get; set; } + public object? Value { get; set; } +} + +/// Discriminated literal kinds mirroring BlockScript's supported types. +public enum BSLiteralKind +{ + String, + Integer, + Double, + Boolean, + Char, + Null +} + +/// An identifier reference (variable / PubVar / ConstBlock name). +public sealed class BSIdentifier : BSExpression +{ + public string Name { get; set; } = string.Empty; +} + +/// +/// A function invocation — the central node of BS. Covers bare calls (Print(x)), +/// member-access calls (PluginName.MethodName(args)), and nested calls used as +/// arguments (Outer(Inner(...)), which BS2CFGConverter expands into PubVar temps). +/// +/// is the short name (last segment); +/// is the full dotted path (TestPlugin.WPF.Core.HelloKitX) for qualified calls. +/// are the structured argument expressions (may themselves be s). +/// preserves each argument's source text for the converters' string-based +/// CFG emission path (preserved verbatim from source, no reformatting). +/// +public sealed class BSCall : BSExpression +{ + public string MethodName { get; set; } = string.Empty; + public string FullMethodName { get; set; } = string.Empty; + public IReadOnlyList Args { get; set; } = Array.Empty(); + public IReadOnlyList RawArgs { get; set; } = Array.Empty(); +} + +/// +/// An assignment: target = value. The target is always an identifier in BS +/// (PubVar / ConstBlock var). The value may be a call, literal, binary, etc. +/// is the whole lhs = rhs text. +/// +public sealed class BSAssignment : BSExpression +{ + public BSIdentifier Target { get; set; } = new(); + public BSExpression Value { get; set; } = new BSIdentifier(); +} + +/// +/// A binary expression. BS only meaningfully uses + (rewritten to StringConcat); +/// other operators may appear inside helper-adjacent expressions and are preserved +/// structurally so the converter can rebuild their text without re-parsing. +/// +public sealed class BSBinary : BSExpression +{ + public BSExpression Left { get; set; } = new BSIdentifier(); + public BSExpression Right { get; set; } = new BSIdentifier(); + public string Operator { get; set; } = string.Empty; +} + +/// A parenthesized sub-expression; the inner expression is what matters. +public sealed class BSParenthesized : BSExpression +{ + public BSExpression Inner { get; set; } = new BSIdentifier(); +} + +/// Extension methods over BSExpression, replacing the Roslyn-coupled ExprUtils helpers. +public static class BSExpressionExtensions +{ + /// + /// Returns the string value when the expression is a string literal, else null. + /// Replaces ExprUtils.GetStringLiteralValue. + /// + public static string? AsStringLiteral(this BSExpression? expr) + => expr is BSLiteral { Kind: BSLiteralKind.String } lit ? lit.Value as string : null; + + /// + /// Returns the typed literal value (string/int/double/bool/char/null), else null. + /// Replaces ExprUtils.GetLiteralValue. + /// + public static object? LiteralValue(this BSExpression? expr) + => expr is BSLiteral lit ? lit.Value : null; + + /// + /// True when the source text is a C# character literal (e.g. '\0', 'a'). + /// Uses Roslyn token parsing once at the boundary (cheaper than a full expression tree); + /// consumers call this on a string rather than re-parsing an expression tree. + /// + public static bool IsCharacterLiteral(string? value) + { + if (string.IsNullOrEmpty(value)) return false; + // Fast pre-check: C# char literals always start and end with single quote. + if (value.Length < 3 || value[0] != '\'' || value[^1] != '\'') return false; + // Validate with Roslyn — parse as a single token (far cheaper than a full expression + // tree parse). A char literal parses as a CharacterLiteralToken. + var token = SyntaxFactory.ParseToken(value); + return token.IsKind(SyntaxKind.CharacterLiteralToken); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// BSExpressionAdapter — the only place Roslyn's C# parser feeds BSExpression. +// Cover every Roslyn node type the converters previously walked directly: +// Invocation / Literal / IdentifierName / Assignment / Binary / Parenthesized. +// ───────────────────────────────────────────────────────────────────────────── + +/// +/// Builds trees from Roslyn syntax nodes. Called once per +/// parsed statement/expression at the extract boundary; the result is then shared by +/// every downstream consumer, eliminating the re-parse-the-string pattern entirely. +/// +public static class BSExpressionAdapter +{ + /// + /// Adapts a Roslyn into a . + /// Returns null for expression kinds BS does not model (should not occur for valid BS). + /// + public static BSExpression? FromRoslyn(ExpressionSyntax expr) + { + switch (expr) + { + case InvocationExpressionSyntax invoke: + return FromInvocation(invoke); + case LiteralExpressionSyntax lit: + return FromLiteral(lit); + case IdentifierNameSyntax id: + return new BSIdentifier { Name = id.Identifier.Text, SourceText = id.ToString() }; + case AssignmentExpressionSyntax assign: + return FromAssignment(assign); + case BinaryExpressionSyntax binary: + return FromBinary(binary); + case ParenthesizedExpressionSyntax paren: + return new BSParenthesized + { + Inner = FromRoslyn(paren.Expression) ?? new BSIdentifier(), + SourceText = paren.ToString() + }; + case MemberAccessExpressionSyntax member: + // A bare member access (not a call) — treat as an identifier-like reference + // carrying the full dotted path. Rare in BS (PluginName.MethodName appears as + // the receiver of a call), but handled so it is never lost. + return new BSIdentifier { Name = member.ToString(), SourceText = member.ToString() }; + default: + return null; + } + } + + /// + /// Parses a free-form expression string (e.g. a Branch/Loop condition text) into a + /// . This is a one-shot Roslyn parse at the boundary — used by + /// the few sites that carry an expression as a string (e.g. FlowControlStatement.ConditionExpression) + /// and need structural analysis. Returns null when the string is not a parseable expression. + /// + public static BSExpression? Parse(string expression) + { + if (string.IsNullOrWhiteSpace(expression)) return null; + try + { + var tree = CSharpSyntaxTree.ParseText("_ = " + expression + ";"); + var root = tree.GetCompilationUnitRoot(); + var globalStmt = root.Members.FirstOrDefault() as GlobalStatementSyntax; + var stmt = globalStmt?.Statement as ExpressionStatementSyntax; + if (stmt?.Expression is AssignmentExpressionSyntax assignment) + return FromRoslyn(assignment.Right); + return stmt?.Expression is null ? null : FromRoslyn(stmt.Expression); + } + catch + { + return null; + } + } + + private static BSCall FromInvocation(InvocationExpressionSyntax invoke) + { + var methodName = GetShortName(invoke); + var fullMethodName = GetFullName(invoke); + var args = new List(invoke.ArgumentList.Arguments.Count); + var rawArgs = new List(invoke.ArgumentList.Arguments.Count); + foreach (var arg in invoke.ArgumentList.Arguments) + { + rawArgs.Add(arg.Expression.ToString()); + args.Add(FromRoslyn(arg.Expression) ?? new BSIdentifier { SourceText = arg.Expression.ToString() }); + } + return new BSCall + { + MethodName = methodName, + FullMethodName = fullMethodName, + Args = args, + RawArgs = rawArgs, + SourceText = invoke.ToString() + }; + } + + private static BSLiteral FromLiteral(LiteralExpressionSyntax lit) + { + var token = lit.Token; + var kind = token.Kind() switch + { + SyntaxKind.StringLiteralToken => BSLiteralKind.String, + SyntaxKind.NumericLiteralToken => token.Value is double or float ? BSLiteralKind.Double : BSLiteralKind.Integer, + SyntaxKind.TrueKeyword => BSLiteralKind.Boolean, + SyntaxKind.FalseKeyword => BSLiteralKind.Boolean, + SyntaxKind.CharacterLiteralToken => BSLiteralKind.Char, + SyntaxKind.NullKeyword => BSLiteralKind.Null, + _ => BSLiteralKind.String + }; + return new BSLiteral { Kind = kind, Value = token.Value, SourceText = lit.ToString() }; + } + + private static BSAssignment FromAssignment(AssignmentExpressionSyntax assign) + { + var target = FromRoslyn(assign.Left) as BSIdentifier + ?? new BSIdentifier { Name = assign.Left.ToString(), SourceText = assign.Left.ToString() }; + var value = FromRoslyn(assign.Right) ?? new BSIdentifier { SourceText = assign.Right.ToString() }; + return new BSAssignment + { + Target = target, + Value = value, + SourceText = assign.ToString() + }; + } + + private static BSBinary FromBinary(BinaryExpressionSyntax binary) + { + var left = FromRoslyn(binary.Left) ?? new BSIdentifier { SourceText = binary.Left.ToString() }; + var right = FromRoslyn(binary.Right) ?? new BSIdentifier { SourceText = binary.Right.ToString() }; + return new BSBinary + { + Left = left, + Right = right, + Operator = binary.OperatorToken.Text, + SourceText = binary.ToString() + }; + } + + // Mirrors the former ExprUtils.GetMethodName / GetFullMethodName — kept here as the + // single source of truth for the Roslyn→name extraction, used only while building a BSCall. + + private static string GetShortName(InvocationExpressionSyntax invoke) => invoke.Expression switch + { + IdentifierNameSyntax id => id.Identifier.Text, + GenericNameSyntax generic => generic.Identifier.Text, + MemberAccessExpressionSyntax member => member.Name.Identifier.Text, + _ => string.Empty + }; + + private static string GetFullName(InvocationExpressionSyntax invoke) => invoke.Expression switch + { + IdentifierNameSyntax id => id.Identifier.Text, + GenericNameSyntax generic => generic.Identifier.Text, + MemberAccessExpressionSyntax member => member.Expression + "." + member.Name.Identifier.Text, + _ => string.Empty + }; +} diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Models/Statements/ExpressionStatement.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Models/Statements/ExpressionStatement.cs index 9c4427f3..f31b0f44 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Models/Statements/ExpressionStatement.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Models/Statements/ExpressionStatement.cs @@ -1,4 +1,4 @@ -using Microsoft.CodeAnalysis.CSharp.Syntax; +using KitX.Workflow.Models; namespace KitX.Workflow.Models.Statements; @@ -13,19 +13,20 @@ public class ExpressionStatement : BlockStatement public string Expression { get; set; } = string.Empty; /// - /// The pre-parsed invocation when this statement was extracted from source by - /// BlockStatementExtractor. Lets BS2CFGConverter skip re-parsing - /// (the double-parse smell). Null when the statement was built - /// programmatically (e.g. by CFG2BSConverter from a CFG); BS2CFGConverter - /// then falls back to parsing . Transient — not preserved across - /// BlockScript text serialization. + /// The pre-parsed BS expression when this statement was extracted from source by + /// BlockStatementExtractor (via BSExpressionAdapter.FromRoslyn), or built + /// programmatically by BP2CFGConverter.GenerateBlockStatement. Lets + /// BS2CFGConverter walk the structured AST directly instead of re-parsing + /// text. Carries whatever shape the RHS has — a , + /// (+ chain), or . Null only when the + /// statement has no analyzable expression body (pure data nodes). Transient — not preserved + /// across BlockScript text serialization. /// - public InvocationExpressionSyntax? ParsedInvocation { get; set; } + public BSExpression? ParsedExpression { get; set; } /// - /// The assigned variable when came from an assignment - /// statement (e.g. x = Func(...)); null for bare expression statements. Mirrors the - /// assignedVar that ExprUtils.ParseStatement would derive, avoiding re-derivation. + /// The assigned variable when came from an assignment + /// statement (e.g. x = Func(...)); null for bare expression statements. /// public string? AssignedVariable { get; set; } -} \ No newline at end of file +} diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Models/Statements/FlowControlStatement.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Models/Statements/FlowControlStatement.cs index 58e4be08..94692c65 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Models/Statements/FlowControlStatement.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Models/Statements/FlowControlStatement.cs @@ -71,28 +71,51 @@ public string? LoopbackTarget /// /// Regenerates SourceCode from current field values. /// Call after updating Arms/ConditionExpression/etc. to keep SourceCode in sync. + /// Delegates to the static so every site that rebuilds BS + /// control-flow source text (this instance method, BP2CFGConverter, CFG2BSConverter) + /// shares one renderer and cannot drift out of sync. /// public void RegenerateSourceCode() { - SourceCode = ControlType switch - { - FlowControlType.ConditionalJump => $"NextBlock = Branch({ConditionExpression}, \"{TrueBlockName}\", \"{FalseBlockName}\");", - FlowControlType.IterativeJump => $"NextBlock = Loop({ConditionExpression}, \"{TrueBlockName}\", \"{FalseBlockName}\");", - FlowControlType.LoopBackedge => !string.IsNullOrEmpty(LoopbackTarget) - ? $"NextBlock = ToLoopCond(\"{LoopbackTarget}\");" - : "ToLoopCond();", - FlowControlType.IndexedDispatch => RegenerateSwitchSource(), - FlowControlType.LoopExit => "Break();", - _ => SourceCode - }; + SourceCode = RenderSource(ControlType, ConditionExpression, Arms, LoopbackTarget); } - private string RegenerateSwitchSource() + /// + /// Single source of truth for rendering a control-flow statement back to BlockScript text. + /// Takes the authoritative shape plus the fields that vary per shape: + /// + /// ConditionalJump/IterativeJump: Branch/Loop(cond, "true", "false"). + /// LoopBackedge: ToLoopCond("target") (or bare ToLoopCond() when no target). + /// IndexedDispatch: Switch(cond, "default", "b0", "b1", ...) — arms[0] is default. + /// LoopExit: Break(). + /// + /// + /// The control-flow shape (FlowControlType). + /// The condition/selector expression text (may be empty for ToLoopCond/Break). + /// The arms; for Switch the layout is [Default, 0, 1, …]; for Branch/Loop + /// arms[0]/arms[1] are true/false. Ignored for ToLoopCond/Break. + /// The ToLoopCond return-to block name (null/empty → bare ToLoopCond()). + public static string RenderSource(FlowControlType shape, string condition, + IReadOnlyList arms, string? loopbackTarget) => shape switch + { + FlowControlType.ConditionalJump => + $"NextBlock = Branch({condition}, \"{arms.ElementAtOrDefault(0)?.TargetBlockName ?? ""}\", \"{arms.ElementAtOrDefault(1)?.TargetBlockName ?? ""}\");", + FlowControlType.IterativeJump => + $"NextBlock = Loop({condition}, \"{arms.ElementAtOrDefault(0)?.TargetBlockName ?? ""}\", \"{arms.ElementAtOrDefault(1)?.TargetBlockName ?? ""}\");", + FlowControlType.LoopBackedge => !string.IsNullOrEmpty(loopbackTarget) + ? $"NextBlock = ToLoopCond(\"{loopbackTarget}\");" + : "ToLoopCond();", + FlowControlType.IndexedDispatch => RenderSwitchSource(condition, arms), + FlowControlType.LoopExit => "Break();", + _ => string.Empty + }; + + private static string RenderSwitchSource(string condition, IReadOnlyList arms) { // Arms layout: [Default, 0, 1, ..., N-1] - if (Arms.Count == 0) return "Switch();"; - var defaultBlock = Arms[0].TargetBlockName; - var blocks = Arms.Skip(1).Select(a => $"\"{a.TargetBlockName}\""); - return $"NextBlock = Switch({ConditionExpression}, \"{defaultBlock}\", {string.Join(", ", blocks)});"; + if (arms.Count == 0) return $"NextBlock = Switch({condition}, \"\");"; + var defaultBlock = arms[0].TargetBlockName; + var blocks = arms.Skip(1).Select(a => $"\"{a.TargetBlockName}\""); + return $"NextBlock = Switch({condition}, \"{defaultBlock}\", {string.Join(", ", blocks)});"; } } From 55351a24ffe1cecc3e80ac4b6dc826b51d8d3bc0 Mon Sep 17 00:00:00 2001 From: StarInk Date: Sun, 21 Jun 2026 18:46:36 +0200 Subject: [PATCH 109/110] =?UTF-8?q?=F0=9F=92=BE=20=F0=9F=A7=A9=20Feat,=20R?= =?UTF-8?q?efactor(Workflow):=20=E5=BC=95=E5=85=A5=E7=AE=A1=E9=81=93?= =?UTF-8?q?=E8=AF=AD=E6=B3=95(\-)+BS=20AST=E6=89=A9=E5=B1=95+CFG=20provena?= =?UTF-8?q?nce-BS=E7=AE=A1=E9=81=93=E8=AF=AD=E6=B3=95=E9=98=B6=E6=AE=B5L16?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 BlockScript 管道操作符 \- ,为数据流式表达提供线性、可读的语法糖,作为嵌套调用的替代。 语法形式: 线性链: Get("x") \- Process \- Print 菱形依赖: Get("a"), Get("b") \- StringConcat \- Print 混合参数: Get("x") \- StringConcat("prefix", _) \- Print 换行: \- 前后可自由换行(C# 语义) 设计与实现: - 符号 \- (反斜杠+减号,无 shift),arity 由逗号源列表表达 - 解析策略:PipelinePreScanner 预处理阶段,在 Roslyn 解析前把 \- 管道重写为 __pipe(sources, __seg(target1), __seg(target2)) 占位形式(Roslyn 不认 \- token) - AST:BSPipeline(Sources/Targets)+ BSPlaceholder(_ 占位符)加入 BSExpression - BlockStatementExtractor 识别 __pipe 重建 BSPipeline,支持裸函数名(0参)和带参调用 - BS2CFG.FormatPipeline:扁平化为 PubVar 赋值序列 + 调用,非终段自动合成 PubVar, 共享 PipelineId provenance(PipelineId/PipelineSegmentIndex 加入 CFGStatement) - CFG2BS:按 PipelineId 分组重建单条管道语句(直接 CFG→BS 路径保留 \- 语法) - 序列化器无需改动(直接输出 SourceCode,管道文本自带 \-) provenance 边界: - 直接 BS→CFG→BS(BS2CFG 设 PipelineId,CFG2BS 重建):\- 语法保留 - 经蓝图 BS→BP→BS(蓝图不携带 PipelineId):\- 展平为命名 PubVar 形式, 语义等价且可重编译,但不保留 \- 字面语法。后者需未来在 BlueprintNode 上存储管道分组元数据(后续工作)。 验证: - 现有测试 A-W(23 场景)全绿,零回归 - 新增测试 X:管道执行(线性链 + 菱形依赖),输出正确(Hello, World / HelloWorld) - 新增测试 Y:管道往返(BS↔BP),语义等价(输出匹配)+ 可重编译 --- .../KitX.Workflow.Test/Program.cs | 213 ++++++++++++++ .../BlockScripting/BlockScriptParser.cs | 7 + .../BlockScripting/BlockStatementExtractor.cs | 90 ++++++ .../BlockScripting/PipelinePreScanner.cs | 273 ++++++++++++++++++ .../KitX.Workflow/CFG/CFGStatement.cs | 16 + .../Conversion/BS2CFGConverter.cs | 146 ++++++++++ .../Conversion/CFG2BSConverter.cs | 49 +++- .../KitX.Workflow/Models/BSExpression.cs | 40 ++- 8 files changed, 832 insertions(+), 2 deletions(-) create mode 100644 KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/PipelinePreScanner.cs diff --git a/KitX Clients/KitX Workflow/KitX.Workflow.Test/Program.cs b/KitX Clients/KitX Workflow/KitX.Workflow.Test/Program.cs index 93130944..d92b1906 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow.Test/Program.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow.Test/Program.cs @@ -271,6 +271,22 @@ public static void Main(string[] args) Console.WriteLine("└──────────────────────────────────────────────────────┘\n"); RunNestedControlFlowTopologyTest(reverseConverter, nodeRegistry, "Test W"); } + + if (ShouldRunTest("X")) + { + Console.WriteLine("\n┌──────────────────────────────────────────────────────────┐"); + Console.WriteLine("│ Test X: Pipeline (\\-) parse + flatten + compile + exec │"); + Console.WriteLine("└──────────────────────────────────────────────────────────┘\n"); + RunPipelineExecutionTest(parser, converter); + } + + if (ShouldRunTest("Y")) + { + Console.WriteLine("\n┌──────────────────────────────────────────────────────────┐"); + Console.WriteLine("│ Test Y: Pipeline (\\-) round-trip fidelity │"); + Console.WriteLine("└──────────────────────────────────────────────────────────┘\n"); + RunPipelineRoundTripTest(parser, converter, reverseConverter); + } } // ────────────────────────────────────────────── @@ -2184,4 +2200,201 @@ private static void RunStringConcatRoundTripTest( Console.WriteLine($"[Test V] {(fails == 0 ? "PASS" : "FAIL - see above")}"); } + + // ────────────────────────────────────────────── + // Test X: Pipeline (\-) parse + flatten + compile + execute. + // Exercises linear chains, diamond (multi-source) dependency, and the _ placeholder. + // ────────────────────────────────────────────── + private static void RunPipelineExecutionTest( + IBlockScriptParser parser, + BlockScriptToBlueprintConverter converter) + { + Console.WriteLine("[Test X] Pipeline (\\-): parse, flatten, compile, execute"); + int fails = 0; + + // X1: linear chain — Get → StringConcat → Print. Verifies single-source pipeline compiles + // and the Print output reflects the data flow through both stages. + var srcLinear = @" +#ConstBlock +string name = ""World""; + +#PubVarBlock +dynamic greeting; + +#MainBlock +Get(""name"") \- StringConcat(""Hello, "", _) \- Print; +"; + + // X2: diamond dependency — two sources (Get + literal) feed StringConcat positionally. + var srcDiamond = @" +#ConstBlock +string a = ""Hello""; +string b = ""World""; + +#MainBlock +Get(""a""), Get(""b"") \- StringConcat \- Print; +"; + + foreach (var (label, src, expectedInOutput) in new[] + { + ("linear", srcLinear, "Hello, World"), + ("diamond", srcDiamond, "HelloWorld"), + }) + { + try + { + var pr = parser.Parse(src); + if (!pr.IsSuccess || pr.Script == null) + { + Console.WriteLine($" [{label}] FAIL: parse error: {pr.ErrorMessage}"); + fails++; + continue; + } + pr.Script.HelperFunctions = new List(); + + var compiled = new CSCompiler().CompileScript(pr.Script, workflowId: null, out var errors); + if (compiled == null) + { + Console.WriteLine($" [{label}] FAIL: compile null, {errors.Count} error(s)"); + foreach (var e in errors.Take(3)) Console.WriteLine($" {e}"); + fails++; + continue; + } + + var output = new List(); + var globals = new BlockScriptExecutionGlobals(new BlockScopeManager(), output); + globals.ResetRunState(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + try + { + compiled.RunAsync(globals, cts.Token).GetAwaiter().GetResult(); + } + catch (OperationCanceledException) + { + Console.WriteLine($" [{label}] FAIL: execution TIMEOUT"); + fails++; + continue; + } + + var ok = output.Any(o => o.Contains(expectedInOutput)); + Console.WriteLine($" [{label}] {(ok ? "PASS" : "FAIL")}: output [{string.Join(", ", output)}] (expected to contain '{expectedInOutput}')"); + if (!ok) fails++; + } + catch (Exception ex) + { + Console.WriteLine($" [{label}] FAIL: {ex.GetType().Name}: {ex.Message}"); + fails++; + } + } + + Console.WriteLine($"[Test X] {(fails == 0 ? "PASS" : "FAIL - see above")}"); + } + + // ────────────────────────────────────────────── + // Test Y: Pipeline (\-) round-trip. + // BS → BP → BS. The pipeline flattens across the blueprint boundary (BlueprintNode does not + // carry CFG PipelineId provenance), so the round-tripped BS is the equivalent flat PubVar + // form — which must still parse and compile, and execute to the same output. Preserving the + // literal \- syntax through the blueprint is a future enhancement (storing pipeline grouping + // on blueprint nodes); for now the contract is semantic equivalence + re-compilability. + // ────────────────────────────────────────────── + private static void RunPipelineRoundTripTest( + IBlockScriptParser parser, + BlockScriptToBlueprintConverter converter, + IBlueprintToBlockScriptConverter reverseConverter) + { + Console.WriteLine("[Test Y] Pipeline (\\-): BS↔BP round-trip (semantic equivalence)"); + int fails = 0; + + var src = @" +#ConstBlock +string name = ""World""; + +#MainBlock +Get(""name"") \- StringConcat(""Hi "", _) \- Print; +"; + + try + { + // Capture original execution output for comparison. + var pr1 = parser.Parse(src); + if (!pr1.IsSuccess || pr1.Script == null) + { + Console.WriteLine(" FAIL: original parse error"); + fails++; + Console.WriteLine($"[Test Y] {(fails == 0 ? "PASS" : "FAIL")}"); + return; + } + pr1.Script.HelperFunctions = new List(); + var compiled1 = new CSCompiler().CompileScript(pr1.Script, workflowId: null, out _); + var origOutput = new List(); + if (compiled1 != null) + { + var g1 = new BlockScriptExecutionGlobals(new BlockScopeManager(), origOutput); + g1.ResetRunState(); + using var cts1 = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + try { compiled1.RunAsync(g1, cts1.Token).GetAwaiter().GetResult(); } + catch { /* timeout — compare anyway */ } + } + + var bpResult = converter.Convert(src, new List()); + if (bpResult == null) + { + Console.WriteLine(" FAIL: BS→BP conversion returned null"); + fails++; + } + else + { + var bsResult = reverseConverter.Convert(bpResult); + Console.WriteLine($" Round-tripped source:\n{bsResult}\n"); + + if (string.IsNullOrEmpty(bsResult)) + { + Console.WriteLine(" FAIL: BP→BS returned empty"); + fails++; + } + else + { + // The round-tripped BS must re-parse and re-compile. + var pr2 = parser.Parse(bsResult); + bool recompiles = false; + List? rtOutput = null; + if (pr2.IsSuccess && pr2.Script != null) + { + pr2.Script.HelperFunctions = new List(); + var compiled2 = new CSCompiler().CompileScript(pr2.Script, workflowId: null, out var errors2); + recompiles = compiled2 != null; + if (!recompiles) + foreach (var e in errors2.Take(3)) Console.WriteLine($" re-compile error: {e}"); + else + { + rtOutput = new List(); + var g2 = new BlockScriptExecutionGlobals(new BlockScopeManager(), rtOutput); + g2.ResetRunState(); + using var cts2 = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + try { compiled2.RunAsync(g2, cts2.Token).GetAwaiter().GetResult(); } + catch { /* timeout */ } + } + } + Console.WriteLine($" Re-parse + re-compile: {(recompiles ? "OK" : "FAIL")}"); + if (!recompiles) fails++; + + // Semantic equivalence: same output. + bool sameOutput = rtOutput != null && origOutput.Count == rtOutput.Count + && origOutput.Zip(rtOutput).All(p => p.First == p.Second); + Console.WriteLine($" Output match (original vs round-trip): {(sameOutput ? "OK" : "DIFF")}"); + Console.WriteLine($" original: [{string.Join(", ", origOutput)}]"); + Console.WriteLine($" round-tripped: [{string.Join(", ", rtOutput ?? new List())}]"); + if (!sameOutput) fails++; + } + } + } + catch (Exception ex) + { + Console.WriteLine($" FAIL: {ex.GetType().Name}: {ex.Message}"); + fails++; + } + + Console.WriteLine($"[Test Y] {(fails == 0 ? "PASS" : "FAIL - see above")}"); + } } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptParser.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptParser.cs index 98564ec6..961911a3 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptParser.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockScriptParser.cs @@ -71,6 +71,10 @@ public BlockScriptParseResult Parse(string sourceCode) foreach (var recognized in recognizer.Blocks) { + // Phase 1.5: Rewrite pipeline (\-) statements into the __pipe placeholder form + // so Roslyn's C# parser can accept them (\- is not a legal C# token). + recognized.Content = PipelinePreScanner.Rewrite(recognized.Content); + // Phase 2: Validate syntax var validationResult = _validator.Validate(recognized); if (!validationResult.IsValid) @@ -184,6 +188,9 @@ public BlockScriptValidationResult Validate(string sourceCode) if (string.IsNullOrWhiteSpace(recognized.Content)) continue; + // Phase 1.5: rewrite pipelines to placeholder form before Roslyn parses. + recognized.Content = PipelinePreScanner.Rewrite(recognized.Content); + var validationResult = _validator.Validate(recognized); if (!validationResult.IsValid) { diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockStatementExtractor.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockStatementExtractor.cs index 3308bb8c..c52ee750 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockStatementExtractor.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockStatementExtractor.cs @@ -198,6 +198,25 @@ private void AddExpressionStatement( if (exprStmt.Expression is InvocationExpressionSyntax invoke) { + // Pipeline statements are rewritten to __pipe(...) by PipelinePreScanner. + // Rebuild a BSPipeline AST node carried as the ParsedExpression of a generic + // ExpressionStatement; BS2CFGConverter.FormatPipeline flattens it. + if (IsPipeSentinel(invoke)) + { + var pipeLine = exprStmt.GetLineNumber(); + var pipeline = BuildPipeline(invoke, pipeLine, exprText); + if (pipeline != null) + { + block.Statements.Add(new ExpressionStatement + { + LineNumber = pipeLine, + SourceCode = exprText, + Expression = exprText, + ParsedExpression = pipeline + }); + return; + } + } AddInvocationStatement(exprStmt, invoke, exprText, assignedVar: null, block); return; } @@ -290,4 +309,75 @@ private static ExpressionStatement BuildExpressionStatement( ParsedExpression = parsedExpression, AssignedVariable = assignedVar }; + + // ─── Pipeline (\-) support ──────────────────────────────────────── + // PipelinePreScanner rewrites `src \- t1 \- t2;` to `__pipe(src, __seg(t1), __seg(t2));`. + // These helpers detect that sentinel form and rebuild a BSPipeline AST node whose Sources + // are the leading non-__seg args and whose Targets are the __seg-wrapped calls. + + private static bool IsPipeSentinel(InvocationExpressionSyntax invoke) + => invoke.Expression is IdentifierNameSyntax id + && id.Identifier.Text == PipelinePreScanner.PipeSentinel; + + /// + /// Builds a from a __pipe(sources..., __seg(t1), __seg(t2)) + /// invocation. Leading arguments that are not __seg calls are the pipeline sources; + /// each __seg(call) argument contributes its inner call as a pipeline target. + /// Returns null when the structure is malformed (no targets). + /// + private static BSPipeline? BuildPipeline(InvocationExpressionSyntax invoke, int lineNumber, string exprText) + { + var sources = new List(); + var targets = new List(); + foreach (var arg in invoke.ArgumentList.Arguments) + { + if (arg.Expression is InvocationExpressionSyntax segInvoke && IsSegSentinel(segInvoke)) + { + // __seg(target) — the single argument is the target. It may be a call with args + // (e.g. __seg(StringConcat("x", _))) or a bare function name with no parens + // (e.g. __seg(Print)), which is a 0-arg target whose inputs come from the pipeline. + var targetExpr = segInvoke.ArgumentList.Arguments.FirstOrDefault()?.Expression; + if (targetExpr is InvocationExpressionSyntax targetInvoke) + { + if (BSExpressionAdapter.FromRoslyn(targetInvoke) is BSCall targetCall) + targets.Add(targetCall); + } + else if (targetExpr is IdentifierNameSyntax targetId) + { + // Bare function name → treat as a 0-arg call; ResolvePipelineArgs fills inputs. + targets.Add(new BSCall + { + MethodName = targetId.Identifier.Text, + FullMethodName = targetId.Identifier.Text, + Args = Array.Empty(), + RawArgs = Array.Empty(), + SourceText = targetId.Identifier.Text + }); + } + } + else + { + // A source expression. + if (BSExpressionAdapter.FromRoslyn(arg.Expression) is { } src) + sources.Add(src); + } + } + + if (targets.Count == 0) + { + Log.Debug("[BlockStatementExtractor] __pipe had no __seg targets: {Expr}", exprText); + return null; + } + + return new BSPipeline + { + Sources = sources, + Targets = targets, + SourceText = exprText + }; + } + + private static bool IsSegSentinel(InvocationExpressionSyntax invoke) + => invoke.Expression is IdentifierNameSyntax id + && id.Identifier.Text == PipelinePreScanner.SegSentinel; } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/PipelinePreScanner.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/PipelinePreScanner.cs new file mode 100644 index 00000000..fc9dbf3e --- /dev/null +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/PipelinePreScanner.cs @@ -0,0 +1,273 @@ +using System.Text; + +namespace KitX.Workflow.BlockScripting; + +/// +/// Pre-scans block content for the pipeline operator \- and rewrites pipeline statements +/// into a valid C# placeholder form so Roslyn's C# parser can accept them (\- is not a +/// legal C# token). +/// +/// A pipeline statement src1, src2 \- Target1 \- Target2; is rewritten to +/// __pipe(src1, src2, __seg(Target1), __seg(Target2));. The +/// recognises the __pipe sentinel and rebuilds a +/// AST node; the rewritten form never reaches the compiled output. +/// +/// +/// The scanner is literal-aware: backslashes inside string/char literals and comments are left +/// untouched. Only a \ immediately followed by - outside any literal/comment is +/// treated as the pipeline operator. +/// +/// +internal static class PipelinePreScanner +{ + /// Sentinel function name emitted for pipeline statements. + public const string PipeSentinel = "__pipe"; + + /// Sentinel function name wrapping each pipeline target segment. + public const string SegSentinel = "__seg"; + + /// + /// Rewrites any pipeline statements in to the placeholder form. + /// Returns the (possibly unchanged) content. Idempotent: content already containing the + /// sentinels is returned verbatim (defensive — the scanner never produces nested pipelines). + /// + public static string Rewrite(string content) + { + if (string.IsNullOrEmpty(content) || !content.Contains('\\')) + return content; + + var result = new StringBuilder(content.Length + 16); + var i = 0; + while (i < content.Length) + { + // Find the next statement boundary or pipeline operator, copying literals verbatim. + var (segment, segmentEnd, hasPipeline) = ReadNextSegment(content, i); + result.Append(hasPipeline ? RewriteSegment(segment) : segment); + i = segmentEnd; + } + return result.ToString(); + } + + /// + /// Reads from index up to and including the next statement terminator + /// (;) or end of input. Returns the segment text, the index past it, and whether the + /// segment contains a top-level pipeline operator. + /// + private static (string text, int end, bool hasPipeline) ReadNextSegment(string content, int start) + { + var sb = new StringBuilder(); + var i = start; + var hasPipeline = false; + while (i < content.Length) + { + var c = content[i]; + + // Line comment — copy to end of line verbatim (no pipeline inside). + if (c == '/' && i + 1 < content.Length && content[i + 1] == '/') + { + var nl = content.IndexOf('\n', i); + var end = nl < 0 ? content.Length : nl + 1; + sb.Append(content, i, end - i); + i = end; + continue; + } + // Block comment — copy verbatim. + if (c == '/' && i + 1 < content.Length && content[i + 1] == '*') + { + var close = content.IndexOf("*/", i + 2, StringComparison.Ordinal); + var end = close < 0 ? content.Length : close + 2; + sb.Append(content, i, end - i); + i = end; + continue; + } + // String literal — copy verbatim (preserves escapes like \", \\). + if (c == '"') + { + var end = SkipStringLiteral(content, i); + sb.Append(content, i, end - i); + i = end; + continue; + } + // Char literal — copy verbatim. + if (c == '\'') + { + var end = SkipCharLiteral(content, i); + sb.Append(content, i, end - i); + i = end; + continue; + } + // Verbatim string (@"...") — copy verbatim, "" escapes the quote. + if (c == '@' && i + 1 < content.Length && content[i + 1] == '"') + { + var end = SkipVerbatimString(content, i + 1); + sb.Append(content, i, end - i); + i = end; + continue; + } + + // Statement terminator — include it and stop. + if (c == ';') + { + sb.Append(c); + return (sb.ToString(), i + 1, hasPipeline); + } + + // Pipeline operator: \ immediately followed by -. + if (c == '\\' && i + 1 < content.Length && content[i + 1] == '-') + { + hasPipeline = true; + sb.Append("\\-"); + i += 2; + continue; + } + + sb.Append(c); + i++; + } + return (sb.ToString(), i, hasPipeline); + } + + /// + /// Rewrites a single pipeline-bearing segment from + /// sources \- t1 \- t2; to __pipe(sources, __seg(t1), __seg(t2));. + /// The segment includes its trailing ; (if any). The sources portion is everything + /// before the first top-level \-; each target is the text between consecutive \- + /// operators (or the trailing ;). + /// + private static string RewriteSegment(string segment) + { + // Split on top-level \- (the scanner already ensured these are outside literals). + var parts = SplitOnPipeline(segment); + if (parts.Count < 2) + return segment; // Defensive: a lone \- with no target is malformed; leave for Roslyn to reject. + + var sources = parts[0].TrimEnd(); + var sb = new StringBuilder(); + sb.Append(PipeSentinel).Append('(').Append(sources); + // Track a trailing statement terminator (;) stripped from the last target — it must sit + // OUTSIDE the __pipe(...) call, not inside __seg(...). + string trailer = ""; + for (int p = 1; p < parts.Count; p++) + { + var target = parts[p].Trim(); + // The last part carries the trailing ';' (statement terminator); peel it off. + if (p == parts.Count - 1 && target.EndsWith(';')) + { + trailer = ";"; + target = target[..^1].TrimEnd(); + } + sb.Append(", ").Append(SegSentinel).Append('(').Append(target).Append(')'); + } + sb.Append(')').Append(trailer); + return sb.ToString(); + } + + /// + /// Splits a pipeline segment on each top-level \- sequence, preserving the rest. + /// Handles the trailing ; by keeping it attached to the last part. + /// + private static List SplitOnPipeline(string segment) + { + var parts = new List(); + var current = new StringBuilder(); + var i = 0; + while (i < segment.Length) + { + var c = segment[i]; + + // Skip over literals/comments wholesale so \- inside them is ignored. + if (c == '/' && i + 1 < segment.Length && segment[i + 1] == '/') + { + var nl = segment.IndexOf('\n', i); + var end = nl < 0 ? segment.Length : nl + 1; + current.Append(segment, i, end - i); + i = end; + continue; + } + if (c == '/' && i + 1 < segment.Length && segment[i + 1] == '*') + { + var close = segment.IndexOf("*/", i + 2, StringComparison.Ordinal); + var end = close < 0 ? segment.Length : close + 2; + current.Append(segment, i, end - i); + i = end; + continue; + } + if (c == '"') + { + var end = SkipStringLiteral(segment, i); + current.Append(segment, i, end - i); + i = end; + continue; + } + if (c == '\'') + { + var end = SkipCharLiteral(segment, i); + current.Append(segment, i, end - i); + i = end; + continue; + } + if (c == '@' && i + 1 < segment.Length && segment[i + 1] == '"') + { + var end = SkipVerbatimString(segment, i + 1); + current.Append(segment, i, end - i); + i = end; + continue; + } + + if (c == '\\' && i + 1 < segment.Length && segment[i + 1] == '-') + { + parts.Add(current.ToString()); + current.Clear(); + i += 2; + continue; + } + + current.Append(c); + i++; + } + parts.Add(current.ToString()); + return parts; + } + + /// Returns the index past a regular string literal starting at the opening quote. + private static int SkipStringLiteral(string s, int start) + { + var i = start + 1; + while (i < s.Length) + { + if (s[i] == '\\') { i += 2; continue; } // escaped char + if (s[i] == '"') return i + 1; + i++; + } + return s.Length; // unterminated — let Roslyn report it + } + + /// Returns the index past a char literal starting at the opening quote. + private static int SkipCharLiteral(string s, int start) + { + var i = start + 1; + while (i < s.Length) + { + if (s[i] == '\\') { i += 2; continue; } + if (s[i] == '\'') return i + 1; + i++; + } + return s.Length; + } + + /// Returns the index past a verbatim string starting at the opening quote (the @ already consumed by caller index-wise). + private static int SkipVerbatimString(string s, int quoteIndex) + { + var i = quoteIndex + 1; + while (i < s.Length) + { + if (s[i] == '"') + { + if (i + 1 < s.Length && s[i + 1] == '"') { i += 2; continue; } // doubled quote + return i + 1; + } + i++; + } + return s.Length; + } +} diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/CFG/CFGStatement.cs b/KitX Clients/KitX Workflow/KitX.Workflow/CFG/CFGStatement.cs index 49bc7932..72e123b1 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/CFG/CFGStatement.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/CFG/CFGStatement.cs @@ -181,4 +181,20 @@ public string? LoopbackTarget /// Same expression → same fingerprint → nodes can be shared. /// public string? Fingerprint { get; set; } + + // --- Pipeline (\-) provenance --- + /// + /// When non-null, this statement is part of a pipeline (\-) that was flattened into + /// sequential PubVar assignments. All statements sharing the same + /// belong to one pipeline and can be reconstructed into a single pipeline statement by + /// CFG2BSConverter. Null for non-pipeline statements (the common case). Pure metadata — + /// the execution layer ignores it. + /// + public string? PipelineId { get; set; } + + /// + /// Ordinal of this statement within its pipeline (), 0-based. + /// -1 for non-pipeline statements. Source assignments come before target calls in index order. + /// + public int PipelineSegmentIndex { get; set; } = -1; } \ No newline at end of file diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BS2CFGConverter.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BS2CFGConverter.cs index f11f1153..9333ff5a 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BS2CFGConverter.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/BS2CFGConverter.cs @@ -205,6 +205,11 @@ private List FormatExpressionStatement(ExpressionStatement exprStm return result; } + // Pipeline (\-) statements flatten into a sequence of PubVar assignments + calls sharing + // a PipelineId for round-trip reconstruction. Handled before the general call/binary paths. + if (rightExpr is BSPipeline pipeline) + return FormatPipeline(pipeline, blockName, context); + var assignedVar = exprStmt.AssignedVariable; // Handle "NextBlock = ..." assignments (should be handled as FlowControl by parser) @@ -517,6 +522,147 @@ private static void CollectAddOperands(BSBinary binary, List opera operands.Add(binary.Right); } + // ────────────────────────────────────────────── + // Pipeline (\-) flattening + // ────────────────────────────────────────────── + + /// + /// Flattens a into a sequence of CFG statements that share a + /// for round-trip reconstruction. + /// + /// Each Source becomes an assignment; each Target becomes a call whose arguments are filled + /// from the current inputs (Sources for the first Target, the previous Target's single result + /// for subsequent ones). Targets containing _ placeholders consume inputs at those + /// positions; targets without placeholders consume all inputs positionally. + /// + /// + /// The flattened form is semantically equivalent to nested calls — the pipeline is pure + /// text-side sugar. The CFG's flat PubVar-assignment form is what executes. + /// + /// + private List FormatPipeline(BSPipeline pipeline, string blockName, PipelineContext context) + { + var result = new List(); + var pipelineId = Guid.NewGuid().ToString(); + int segIndex = 0; + // Preserve the verbatim pipeline source text on the first segment so CFG2BSConverter can + // rebuild the pipeline statement text without re-deriving it from the flattened form. + var pipelineSource = pipeline.SourceText; + + // ── Sources: expand each (nested calls → temp PubVars) and record as assignments. ── + // The PubVar name (or literal/identifier text) of each source is the "current input" + // fed to the first Target. + var currentInputs = new List(); + foreach (var source in pipeline.Sources) + { + var (srcStmts, srcExpr) = ExpandExpression(source, blockName, context); + foreach (var s in srcStmts) + { + s.PipelineId = pipelineId; + s.PipelineSegmentIndex = segIndex++; + result.Add(s); + } + currentInputs.Add(srcExpr); + } + + // ── Targets: fill arguments from current inputs, emit one call each. ── + // Each non-terminal target synthesizes a PubVar to hold its result so the next segment + // can consume it; the terminal target (last) is a bare side-effect call with no PubVar. + for (int t = 0; t < pipeline.Targets.Count; t++) + { + var target = pipeline.Targets[t]; + bool isTerminal = t == pipeline.Targets.Count - 1; + var resolvedArgs = ResolvePipelineArgs(target, currentInputs, blockName, context, result, pipelineId, ref segIndex); + + // Synthesize a PubVar target for non-terminal segments so the result flows forward. + string? pubVarTarget = null; + if (!isTerminal) + { + pubVarTarget = ExprUtils.GeneratePubVarName(context.NextPubVarCounter++); + if (!context.PubVarNames.Contains(pubVarTarget)) + context.PubVarNames.Add(pubVarTarget); + } + + var funcDef = _functionRegistry?.Get(target.MethodName); + var stmt = new CFGStatement + { + BlockName = blockName, + Kind = pubVarTarget != null ? CFGStatementKind.Assignment : CFGStatementKind.Expression, + FlowControlShape = funcDef?.FlowControlShape, + FunctionName = target.MethodName, + FullFunctionName = target.FullMethodName, + PubVarTarget = pubVarTarget, + Arguments = resolvedArgs, + OriginalExpression = pubVarTarget != null + ? $"{pubVarTarget} = {target.MethodName}({string.Join(", ", resolvedArgs)})" + : $"{target.MethodName}({string.Join(", ", resolvedArgs)})", + Fingerprint = ExprUtils.ComputeFingerprint(target.MethodName, resolvedArgs), + PipelineId = pipelineId, + PipelineSegmentIndex = segIndex++ + }; + if (string.IsNullOrEmpty(stmt.StatementId)) + stmt.StatementId = Guid.NewGuid().ToString(); + result.Add(stmt); + + currentInputs = pubVarTarget != null ? new List { pubVarTarget } : new List(); + } + + // Stamp the verbatim pipeline source text on the first segment so CFG2BS can rebuild + // the pipeline statement without re-deriving it from the flattened PubVar form. + if (result.Count > 0 && !string.IsNullOrEmpty(pipelineSource)) + result[0].OriginalExpression = pipelineSource; + + return result; + } + + /// + /// Resolves a pipeline target's arguments to concrete PubVar/literal strings, substituting + /// _ placeholders and non-placeholder args. When the target has placeholders, each + /// placeholder consumes one input in order; the remaining (literal) args are kept verbatim. + /// When the target has no placeholders, all current inputs fill the argument positions in order. + /// Any nested calls in non-placeholder args are expanded (appended to ). + /// + private List ResolvePipelineArgs(BSCall target, List currentInputs, + string blockName, PipelineContext context, List result, + string pipelineId, ref int segIndex) + { + var placeholders = target.Args.OfType().ToList(); + var resolved = new List(); + int inputCursor = 0; + + foreach (var arg in target.Args) + { + if (arg is BSPlaceholder) + { + resolved.Add(inputCursor < currentInputs.Count + ? currentInputs[inputCursor++] + : "null"); + } + else + { + // Expand any nested call in this arg, then use its final expression string. + var (nested, finalExpr) = ExpandExpression(arg, blockName, context); + foreach (var s in nested) + { + s.PipelineId = pipelineId; + s.PipelineSegmentIndex = segIndex++; + result.Add(s); + } + resolved.Add(finalExpr); + } + } + + // No placeholders → fill positional slots from currentInputs if the call had no explicit args. + // (e.g. `Get("a"), Get("b") \- StringConcat` — StringConcat has no args, all inputs apply.) + if (placeholders.Count == 0 && target.Args.Count == 0) + { + foreach (var input in currentInputs) + resolved.Add(input); + } + + return resolved; + } + // ────────────────────────────────────────────── // Condition expansion (for Branch/Loop) // ────────────────────────────────────────────── diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFG2BSConverter.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFG2BSConverter.cs index 204ab1e7..a3635f14 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFG2BSConverter.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Conversion/CFG2BSConverter.cs @@ -84,11 +84,35 @@ public BlockScript Generate(ControlFlowGraph cfg) NextBlockName = cfgBlock.FallThroughTarget }; - foreach (var cfgStmt in cfgBlock.Statements) + // Iterate statements, batching pipeline (\-) groups: all statements sharing a + // PipelineId are segments of one flattened pipeline and are rebuilt into a single + // pipeline statement whose text was preserved on the first segment. + var i = 0; + while (i < cfgBlock.Statements.Count) { + var cfgStmt = cfgBlock.Statements[i]; + + // Pipeline group: collect all consecutive statements with the same PipelineId. + if (!string.IsNullOrEmpty(cfgStmt.PipelineId)) + { + var pid = cfgStmt.PipelineId; + var group = new List(); + while (i < cfgBlock.Statements.Count + && cfgBlock.Statements[i].PipelineId == pid) + { + group.Add(cfgBlock.Statements[i]); + i++; + } + var pipelineStmt = ConvertPipelineGroup(group); + if (pipelineStmt != null) + blockDef.Statements.Add(pipelineStmt); + continue; + } + var blockStmt = ConvertStatement(cfgStmt); if (blockStmt != null) blockDef.Statements.Add(blockStmt); + i++; } if (cfgBlock.IsMainBlock) @@ -108,6 +132,29 @@ public BlockScript Generate(ControlFlowGraph cfg) // ─── Statement Conversion ────────────────────────────────────────── + /// + /// Rebuilds a flattened pipeline group (statements sharing a PipelineId) into a single + /// pipeline statement. The verbatim pipeline source text was stamped on the first segment's + /// by BS2CFGConverter.FormatPipeline; we emit it + /// as one ExpressionStatement. On re-parse, PipelinePreScanner rewrites the \- syntax + /// and BlockStatementExtractor rebuilds the BSPipeline AST, so no AST reconstruction is needed + /// here — the text carries the structure. + /// + private static BlockStatement? ConvertPipelineGroup(List group) + { + if (group.Count == 0) return null; + var first = group[0]; + var source = first.OriginalExpression; + if (string.IsNullOrEmpty(source)) return null; + return new ExpressionStatement + { + StatementId = first.StatementId, + Expression = ExtractExpression(source), + SourceCode = source, + LineNumber = first.SourceLine + }; + } + private static BlockStatement? ConvertStatement(CFGStatement cfgStmt) { // Control flow statements → FlowControlStatement diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Models/BSExpression.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Models/BSExpression.cs index fef06ea7..36de3c66 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Models/BSExpression.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Models/BSExpression.cs @@ -111,6 +111,41 @@ public sealed class BSParenthesized : BSExpression public BSExpression Inner { get; set; } = new BSIdentifier(); } +/// +/// A pipeline statement: SourceList \- Target { \- Target }. +/// +/// is the comma-separated list of source expressions (1 or more). +/// is the ordered list of pipeline segments (each a , +/// which may contain arguments marking where pipeline values insert). +/// +/// +/// Flattening (BS→CFG): each Source becomes a PubVar assignment; each Target becomes a call +/// statement whose arguments are filled from Sources (first segment) or the previous segment's +/// single result (subsequent segments). All statements share a PipelineId for round-trip +/// reconstruction. The CFG's flat semantics are unchanged — the pipeline is purely a text-side +/// sugar whose structure is captured as provenance metadata. +/// +/// +public sealed class BSPipeline : BSExpression +{ + /// The comma-separated source expressions (the left side of the first \-). + public IReadOnlyList Sources { get; set; } = Array.Empty(); + + /// The ordered pipeline segments (each \- Target). + public IReadOnlyList Targets { get; set; } = Array.Empty(); +} + +/// +/// A pipeline placeholder (_) — marks a parameter position where a pipeline value +/// (from Sources or the previous segment's result) should be inserted during flattening. +/// is the ordinal among multiple placeholders in the same call (0-based), +/// used to match pipeline values to positions when a target has more than one _. +/// +public sealed class BSPlaceholder : BSExpression +{ + public int Index { get; set; } +} + /// Extension methods over BSExpression, replacing the Roslyn-coupled ExprUtils helpers. public static class BSExpressionExtensions { @@ -171,7 +206,10 @@ public static class BSExpressionAdapter case LiteralExpressionSyntax lit: return FromLiteral(lit); case IdentifierNameSyntax id: - return new BSIdentifier { Name = id.Identifier.Text, SourceText = id.ToString() }; + // The lone underscore is the pipeline placeholder, not a variable reference. + return id.Identifier.Text == "_" + ? new BSPlaceholder { SourceText = "_" } + : new BSIdentifier { Name = id.Identifier.Text, SourceText = id.ToString() }; case AssignmentExpressionSyntax assign: return FromAssignment(assign); case BinaryExpressionSyntax binary: From 85dd3d238b11345ff1dfc05998ec26302a7fb158 Mon Sep 17 00:00:00 2001 From: StarInk Date: Sun, 21 Jun 2026 20:23:49 +0200 Subject: [PATCH 110/110] =?UTF-8?q?=F0=9F=A7=A9=20Refactor(Workflow):=20?= =?UTF-8?q?=E7=AE=A1=E9=81=93=E7=AC=A6=E5=8F=B7=E5=88=87=E6=8D=A2\->>+?= =?UTF-8?q?=E5=B5=8C=E5=A5=97=E5=BC=83=E7=94=A8+=E6=A8=A1=E6=9D=BF?= =?UTF-8?q?=E5=8E=BB=E5=B5=8C=E5=A5=97+=E6=B5=8B=E8=AF=95=E9=80=82?= =?UTF-8?q?=E9=85=8D+AI=5F*=E6=B8=85=E7=90=86+=E8=AF=AD=E6=B3=95=E6=96=87?= =?UTF-8?q?=E6=A1=A3v4.0-L18?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 符号切换: - PipelinePreScanner: \- 改为 > 作为管道操作符(单字符,无 JSON 转义,BS 无泛型歧义) - 测试 X/Y: 管道语法从 \- 更新为 > 嵌套调用弃用: - BlockStatementExtractor.ExtractStatements: 检测嵌套函数调用(InvocationExpressionSyntax 出现在另一个调用的参数列表中)并发 BS_DEPRECATED_NESTING 警告(非致命) - ContainsNestedCall 辅助方法:递归扫描表达式树,跳过 __pipe/__seg 哨兵 默认模板去嵌套(WorkflowStorageService.GetDefaultBlockScriptTemplate): - 循环条件:展开为显式 PubVar 赋值(vaaa0002=Get(...); vaaa0001=HelperFuncCompare(...)) - 简单数据流:用管道语法(Get > Print, Get > HelperFuncAdd > Set) - 分支条件:预计算到 PubVar 后供 Branch 读取 - 全程零嵌套,管道仅用于安全的线性链 测试适配: - H/I: 重写为预展开 PubVar 形式(vaaa#### 命名,无嵌套),往返一致性测试通过 - T: 改写为展开形式(PluginCall 赋值给 PubVar,无嵌套) - U1: 改用 GetSequentialScript(无嵌套,clean script 零警告) - B/K/L/Q/R: 保留嵌套形式——直接编译正常,仅发弃用警告;预展开形式经往返后 CFG2CS 存在变量声明排序问题(CS0841),需编译后端修复后才能去嵌套 工作流清理: - 删除 12 个 AI_* .kcs 文件(SDK 6 + 运行时 a0000001-* 6) (AI 插件重写后脚本将过时,无保留价值) - Chatbot.kcs 保留(AI 插件重写时一并更新) 文档: - BlockScriptGrammarRule.md: v3.1→v4.0 - 新增 §5.5 管道操作符(>):语法规则、语义规则、占位符 _、示例 - §5.6 嵌套调用标记为 DEPRECATED:迁移指南(管道/显式赋值) - EBNF 新增 PipelineStatement/SourceList/PipelineTarget/Placeholder 产生式 - §8 完整示例更新 验证: KitX.Workflow.Test --test all (A-Y, 25 场景) 全绿,零回归。 --- .../KitX.Workflow.Test/Program.cs | 97 +++++++++++-------- .../BlockScripting/BlockStatementExtractor.cs | 40 +++++++- .../BlockScripting/PipelinePreScanner.cs | 44 +++++---- .../Services/WorkflowStorageService.cs | 30 +++--- 4 files changed, 136 insertions(+), 75 deletions(-) diff --git a/KitX Clients/KitX Workflow/KitX.Workflow.Test/Program.cs b/KitX Clients/KitX Workflow/KitX.Workflow.Test/Program.cs index d92b1906..c972ef26 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow.Test/Program.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow.Test/Program.cs @@ -275,7 +275,7 @@ public static void Main(string[] args) if (ShouldRunTest("X")) { Console.WriteLine("\n┌──────────────────────────────────────────────────────────┐"); - Console.WriteLine("│ Test X: Pipeline (\\-) parse + flatten + compile + exec │"); + Console.WriteLine("│ Test X: Pipeline (>) parse + flatten + compile + exec │"); Console.WriteLine("└──────────────────────────────────────────────────────────┘\n"); RunPipelineExecutionTest(parser, converter); } @@ -283,7 +283,7 @@ public static void Main(string[] args) if (ShouldRunTest("Y")) { Console.WriteLine("\n┌──────────────────────────────────────────────────────────┐"); - Console.WriteLine("│ Test Y: Pipeline (\\-) round-trip fidelity │"); + Console.WriteLine("│ Test Y: Pipeline (>) round-trip fidelity │"); Console.WriteLine("└──────────────────────────────────────────────────────────┘\n"); RunPipelineRoundTripTest(parser, converter, reverseConverter); } @@ -301,10 +301,12 @@ private static void RunDiagnosticsTest( { bool allPassed = true; - // U1: a valid script produces NO diagnostics. + // U1: a valid, nesting-free script produces NO diagnostics. + // (GetSequentialScript is used because the deprecated nested form now emits + // BS_DEPRECATED_NESTING warnings, which is correct behaviour, not a clean script.) try { - converter.Convert(GetRawNestedScript(), helpers); + converter.Convert(GetSequentialScript(), helpers); var diag = converter.LastDiagnostics; bool clean = diag == null || (!diag.HasErrors && !diag.HasWarnings); Console.WriteLine($" [U1] Clean script diagnostics: {(clean ? "none (expected)" : diag!.Format())}"); @@ -734,7 +736,10 @@ private static string GetPreExpandedScript() => @"#ConstBlock Print(""示例工作流结束"");"; // ────────────────────────────────────────────── - // Test B: Raw nested format (BS2CFGConverter must expand) + // Test B: Raw nested format — exercises the BS2CFGConverter nested-call expander. Nested + // calls are deprecated (BS_DEPRECATED_NESTING warning) but still supported; this test guards + // the expander until nesting is fully removed in a future commit (pending a CFG2CS variable- + // ordering fix that the pre-expanded form needs to compile cleanly through round-trip). // ────────────────────────────────────────────── private static string GetRawNestedScript() => @"#ConstBlock int guessNum = 5; @@ -1072,54 +1077,72 @@ private static string GetNoConstScript() => @"#MainBlock Print(""Done"");"; // ────────────────────────────────────────────── - // Test H: Break inside Loop + // Test H: Break inside Loop (pre-expanded PubVar form, no nested calls) // ────────────────────────────────────────────── private static string GetBreakScript() => @"#ConstBlock int maxIter = 10; int target = 3; +#PubVarBlock +bool vaaa0001; +int vaaa0002; + #MainBlock Set(""i"", 0); -NextBlock = Loop(HelperFuncCompare(""BLE"", Get(""i""), maxIter), ""LoopBody"", ""AfterLoop""); +vaaa0002 = Get(""i""); +vaaa0001 = HelperFuncCompare(""BLE"", vaaa0002, maxIter); +NextBlock = Loop(vaaa0001, ""LoopBody"", ""AfterLoop""); #Block LoopBody -NextBlock = Branch( - HelperFuncCompare(""BEQ"", Get(""i""), target), - ""BreakBlock"", - ""ContinueBlock"" -); +vaaa0002 = Get(""i""); +vaaa0001 = HelperFuncCompare(""BEQ"", vaaa0002, target); +NextBlock = Branch(vaaa0001, ""BreakBlock"", ""ContinueBlock""); #Block BreakBlock Break(); #Block ContinueBlock -Set(""i"", HelperFuncAdd(Get(""i""), 1)); +vaaa0002 = Get(""i""); +vaaa0002 = HelperFuncAdd(vaaa0002, 1); +Set(""i"", vaaa0002); NextBlock = ToLoopCond(""MainBlock""); #Block AfterLoop Print(""Loop finished with break"");"; // ────────────────────────────────────────────── - // Test I: Nested Loop + // Test I: Nested Loop (pre-expanded PubVar form, no nested calls) // ────────────────────────────────────────────── private static string GetNestedLoopScript() => @"#ConstBlock int outerMax = 2; int innerMax = 3; +#PubVarBlock +bool vaaa0001; +int vaaa0002; + #MainBlock Set(""outer"", 0); -NextBlock = Loop(HelperFuncCompare(""BLT"", Get(""outer""), outerMax), ""OuterBody"", ""Done""); +vaaa0002 = Get(""outer""); +vaaa0001 = HelperFuncCompare(""BLT"", vaaa0002, outerMax); +NextBlock = Loop(vaaa0001, ""OuterBody"", ""Done""); #Block OuterBody Set(""inner"", 0); -NextBlock = Loop(HelperFuncCompare(""BLT"", Get(""inner""), innerMax), ""InnerBody"", ""OuterEnd""); +vaaa0002 = Get(""inner""); +vaaa0001 = HelperFuncCompare(""BLT"", vaaa0002, innerMax); +NextBlock = Loop(vaaa0001, ""InnerBody"", ""OuterEnd""); #Block InnerBody -Set(""inner"", HelperFuncAdd(Get(""inner""), 1)); +vaaa0002 = Get(""inner""); +vaaa0002 = HelperFuncAdd(vaaa0002, 1); +Set(""inner"", vaaa0002); NextBlock = ToLoopCond(""OuterBody""); #Block OuterEnd -Set(""outer"", HelperFuncAdd(Get(""outer""), 1)); +vaaa0002 = Get(""outer""); +vaaa0002 = HelperFuncAdd(vaaa0002, 1); +Set(""outer"", vaaa0002); NextBlock = ToLoopCond(""MainBlock""); #Block Done @@ -2009,33 +2032,31 @@ private static void RunQualifiedPluginCallTest(IBlockScriptParser parser) } } - // Test T: Nested PluginCall inside Set — does the BS→CFG expander flatten it - // into a temp PubVar + standalone PluginCall, the way it does for Helper functions - // (Test B/C exercise HelperFuncAdd(Get(...), 1))? If yes, the generated C# should - // compile cleanly. If no, the nested PluginCall is emitted verbatim and fails with - // CS0103 'PluginCall does not exist' — a real converter gap worth fixing. + // Test T: PluginCall assigned to a PubVar — does the BS→CFG converter lower a value-producing + // PluginCall (builtin or dotted) into a temp PubVar + the call, so the generated C# compiles + // cleanly? The expanded form is the nesting-free successor to the former nested Set. private static void RunNestedPluginCallTest(IBlockScriptParser parser) { - Console.WriteLine("[Test T] Nested PluginCall as Set argument — expander coverage check"); + Console.WriteLine("[Test T] PluginCall assigned to PubVar — expander coverage check"); - // Form 1: builtin PluginCall(plugin, method, args) nested in Set. + // Form 1: builtin PluginCall(plugin, method, args) assigned to a PubVar. var sourceBuiltin = @" #PubVarBlock dynamic result; #MainBlock -Set(""result"", PluginCall(""TestPlugin"", ""Echo"", ""hello"")); -Print(Get(""result"")); +result = PluginCall(""TestPlugin"", ""Echo"", ""hello""); +Print(result); "; - // Form 2: dotted Plugin.Method(args) nested in Set. + // Form 2: dotted Plugin.Method(args) assigned to a PubVar. var sourceDotted = @" #PubVarBlock dynamic result; #MainBlock -Set(""result"", TestPlugin.Echo(""hello"")); -Print(Get(""result"")); +result = TestPlugin.Echo(""hello""); +Print(result); "; int fails = 0; @@ -2202,14 +2223,14 @@ private static void RunStringConcatRoundTripTest( } // ────────────────────────────────────────────── - // Test X: Pipeline (\-) parse + flatten + compile + execute. + // Test X: Pipeline (>) parse + flatten + compile + execute. // Exercises linear chains, diamond (multi-source) dependency, and the _ placeholder. // ────────────────────────────────────────────── private static void RunPipelineExecutionTest( IBlockScriptParser parser, BlockScriptToBlueprintConverter converter) { - Console.WriteLine("[Test X] Pipeline (\\-): parse, flatten, compile, execute"); + Console.WriteLine("[Test X] Pipeline (>): parse, flatten, compile, execute"); int fails = 0; // X1: linear chain — Get → StringConcat → Print. Verifies single-source pipeline compiles @@ -2222,7 +2243,7 @@ private static void RunPipelineExecutionTest( dynamic greeting; #MainBlock -Get(""name"") \- StringConcat(""Hello, "", _) \- Print; +Get(""name"") > StringConcat(""Hello, "", _) > Print; "; // X2: diamond dependency — two sources (Get + literal) feed StringConcat positionally. @@ -2232,7 +2253,7 @@ private static void RunPipelineExecutionTest( string b = ""World""; #MainBlock -Get(""a""), Get(""b"") \- StringConcat \- Print; +Get(""a""), Get(""b"") > StringConcat > Print; "; foreach (var (label, src, expectedInOutput) in new[] @@ -2291,11 +2312,11 @@ private static void RunPipelineExecutionTest( } // ────────────────────────────────────────────── - // Test Y: Pipeline (\-) round-trip. + // Test Y: Pipeline (>) round-trip. // BS → BP → BS. The pipeline flattens across the blueprint boundary (BlueprintNode does not // carry CFG PipelineId provenance), so the round-tripped BS is the equivalent flat PubVar // form — which must still parse and compile, and execute to the same output. Preserving the - // literal \- syntax through the blueprint is a future enhancement (storing pipeline grouping + // literal > syntax through the blueprint is a future enhancement (storing pipeline grouping // on blueprint nodes); for now the contract is semantic equivalence + re-compilability. // ────────────────────────────────────────────── private static void RunPipelineRoundTripTest( @@ -2303,7 +2324,7 @@ private static void RunPipelineRoundTripTest( BlockScriptToBlueprintConverter converter, IBlueprintToBlockScriptConverter reverseConverter) { - Console.WriteLine("[Test Y] Pipeline (\\-): BS↔BP round-trip (semantic equivalence)"); + Console.WriteLine("[Test Y] Pipeline (>): BS↔BP round-trip (semantic equivalence)"); int fails = 0; var src = @" @@ -2311,7 +2332,7 @@ private static void RunPipelineRoundTripTest( string name = ""World""; #MainBlock -Get(""name"") \- StringConcat(""Hi "", _) \- Print; +Get(""name"") > StringConcat(""Hi "", _) > Print; "; try diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockStatementExtractor.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockStatementExtractor.cs index c52ee750..df311b05 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockStatementExtractor.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/BlockStatementExtractor.cs @@ -133,6 +133,23 @@ private void ExtractStatements( Log.Debug(" - Expression type: {ExprType}, Text: {Text}", es.Expression.GetType().Name, es.Expression.ToString()); } + // Nested-call deprecation: warn when a function call appears as an argument of another + // call (e.g. Outer(Inner(x))). The pipeline operator (>) is the preferred replacement; + // nested calls are slated for removal. Non-fatal — the expander still handles them. + if (diagnostics != null) + { + foreach (var es in exprStatements) + { + if (ContainsNestedCall(es)) + { + diagnostics.AddWarning("BS_DEPRECATED_NESTING", + "Nested function calls are deprecated; use the pipeline operator (>) instead. " + + "For example, rewrite 'Print(Get(\"x\"))' as 'Get(\"x\") > Print'.", + es.GetLineNumber()); + } + } + } + foreach (var node in root.DescendantNodes()) { switch (node) @@ -311,7 +328,7 @@ private static ExpressionStatement BuildExpressionStatement( }; // ─── Pipeline (\-) support ──────────────────────────────────────── - // PipelinePreScanner rewrites `src \- t1 \- t2;` to `__pipe(src, __seg(t1), __seg(t2));`. + // PipelinePreScanner rewrites `src > t1 > t2;` to `__pipe(src, __seg(t1), __seg(t2));`. // These helpers detect that sentinel form and rebuild a BSPipeline AST node whose Sources // are the leading non-__seg args and whose Targets are the __seg-wrapped calls. @@ -380,4 +397,25 @@ private static bool IsPipeSentinel(InvocationExpressionSyntax invoke) private static bool IsSegSentinel(InvocationExpressionSyntax invoke) => invoke.Expression is IdentifierNameSyntax id && id.Identifier.Text == PipelinePreScanner.SegSentinel; + + /// + /// Returns true when contains a nested function call — i.e. an + /// whose arguments themselves contain another + /// invocation (e.g. Outer(Inner(x))). Used to emit the BS_DEPRECATED_NESTING warning. + /// The top-level call itself does not count; only a call nested inside another call's args. + /// + private static bool ContainsNestedCall(SyntaxNode node) + { + // Find every invocation; if any invocation has an invocation among its argument + // descendants, it's a nested call. + foreach (var invoke in node.DescendantNodes().OfType()) + { + // Skip the __pipe/__seg sentinels (pipeline rewrites — not user nesting). + if (IsPipeSentinel(invoke) || IsSegSentinel(invoke)) + continue; + if (invoke.ArgumentList.Arguments.Any(a => a.DescendantNodes().OfType().Any())) + return true; + } + return false; + } } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/PipelinePreScanner.cs b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/PipelinePreScanner.cs index fc9dbf3e..acd979a2 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/PipelinePreScanner.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/BlockScripting/PipelinePreScanner.cs @@ -3,19 +3,20 @@ namespace KitX.Workflow.BlockScripting; /// -/// Pre-scans block content for the pipeline operator \- and rewrites pipeline statements -/// into a valid C# placeholder form so Roslyn's C# parser can accept them (\- is not a -/// legal C# token). +/// Pre-scans block content for the pipeline operator > and rewrites pipeline statements +/// into a valid C# placeholder form so Roslyn's C# parser can accept them (a bare > +/// between expressions is not a legal C# construct). /// -/// A pipeline statement src1, src2 \- Target1 \- Target2; is rewritten to +/// A pipeline statement src1, src2 > Target1 > Target2; is rewritten to /// __pipe(src1, src2, __seg(Target1), __seg(Target2));. The /// recognises the __pipe sentinel and rebuilds a /// AST node; the rewritten form never reaches the compiled output. /// /// -/// The scanner is literal-aware: backslashes inside string/char literals and comments are left -/// untouched. Only a \ immediately followed by - outside any literal/comment is -/// treated as the pipeline operator. +/// The scanner is literal-aware: a > inside string/char literals or comments is left +/// untouched. Only a > outside any literal/comment is treated as the pipeline operator. +/// BS forbids comparison operators, so there is no ambiguity with > meaning +/// "greater-than". /// /// internal static class PipelinePreScanner @@ -33,7 +34,7 @@ internal static class PipelinePreScanner /// public static string Rewrite(string content) { - if (string.IsNullOrEmpty(content) || !content.Contains('\\')) + if (string.IsNullOrEmpty(content) || !content.Contains('>')) return content; var result = new StringBuilder(content.Length + 16); @@ -112,12 +113,13 @@ private static (string text, int end, bool hasPipeline) ReadNextSegment(string c return (sb.ToString(), i + 1, hasPipeline); } - // Pipeline operator: \ immediately followed by -. - if (c == '\\' && i + 1 < content.Length && content[i + 1] == '-') + // Pipeline operator: > (single char). BS forbids comparison operators so this is + // unambiguous outside literals/comments. + if (c == '>') { hasPipeline = true; - sb.Append("\\-"); - i += 2; + sb.Append('>'); + i++; continue; } @@ -129,17 +131,17 @@ private static (string text, int end, bool hasPipeline) ReadNextSegment(string c /// /// Rewrites a single pipeline-bearing segment from - /// sources \- t1 \- t2; to __pipe(sources, __seg(t1), __seg(t2));. + /// sources > t1 > t2; to __pipe(sources, __seg(t1), __seg(t2));. /// The segment includes its trailing ; (if any). The sources portion is everything - /// before the first top-level \-; each target is the text between consecutive \- - /// operators (or the trailing ;). + /// before the first top-level >; each target is the text between consecutive + /// > operators (or the trailing ;). /// private static string RewriteSegment(string segment) { - // Split on top-level \- (the scanner already ensured these are outside literals). + // Split on top-level > (the scanner already ensured these are outside literals). var parts = SplitOnPipeline(segment); if (parts.Count < 2) - return segment; // Defensive: a lone \- with no target is malformed; leave for Roslyn to reject. + return segment; // Defensive: a lone > with no target is malformed; leave for Roslyn to reject. var sources = parts[0].TrimEnd(); var sb = new StringBuilder(); @@ -163,7 +165,7 @@ private static string RewriteSegment(string segment) } /// - /// Splits a pipeline segment on each top-level \- sequence, preserving the rest. + /// Splits a pipeline segment on each top-level >, preserving the rest. /// Handles the trailing ; by keeping it attached to the last part. /// private static List SplitOnPipeline(string segment) @@ -175,7 +177,7 @@ private static List SplitOnPipeline(string segment) { var c = segment[i]; - // Skip over literals/comments wholesale so \- inside them is ignored. + // Skip over literals/comments wholesale so > inside them is ignored. if (c == '/' && i + 1 < segment.Length && segment[i + 1] == '/') { var nl = segment.IndexOf('\n', i); @@ -214,11 +216,11 @@ private static List SplitOnPipeline(string segment) continue; } - if (c == '\\' && i + 1 < segment.Length && segment[i + 1] == '-') + if (c == '>') { parts.Add(current.ToString()); current.Clear(); - i += 2; + i++; continue; } diff --git a/KitX Clients/KitX Workflow/KitX.Workflow/Services/WorkflowStorageService.cs b/KitX Clients/KitX Workflow/KitX.Workflow/Services/WorkflowStorageService.cs index e341da99..6ff9f5bc 100644 --- a/KitX Clients/KitX Workflow/KitX.Workflow/Services/WorkflowStorageService.cs +++ b/KitX Clients/KitX Workflow/KitX.Workflow/Services/WorkflowStorageService.cs @@ -298,7 +298,9 @@ private static async Task SaveKcsFileInternalAsync(string filePath, KcsFileForma /// /// Returns a default BlockScript template for new workflows. /// Includes a complete guessing game example demonstrating - /// ConstBlock, PubVarBlock, MainBlock, Loop, Branch, and custom blocks. + /// ConstBlock, PubVarBlock, MainBlock, the pipeline operator (>) for simple data flow, + /// Loop, Branch, and custom blocks. Nesting-free — conditions are pre-computed into + /// PubVars, and simple chains use the pipeline operator. /// private static string GetDefaultBlockScriptTemplate() { @@ -318,25 +320,23 @@ private static string GetDefaultBlockScriptTemplate() NextBlock = ""LoopCond""; #Block LoopCond -vaaa0001 = HelperFuncCompare(""BLE"", Get(""currentLoop""), loopMax); +// Pre-compute the loop condition (read currentLoop → compare → store bool). +vaaa0002 = Get(""currentLoop""); +vaaa0001 = HelperFuncCompare(""BLE"", vaaa0002, loopMax); NextBlock = Loop(vaaa0001, ""LoopBody"", ""EndLogic""); #Block LoopBody -vaaa0002 = Get(""currentLoop""); -Print(vaaa0002); -Set(""currentLoop"", HelperFuncAdd(Get(""currentLoop""), 1)); -NextBlock = Branch( - HelperFuncCompare(""BEQ"", guessNum, targetNum), - ""SuccessLogic"", - ""CheckLogic"" -); +// Pipeline: read currentLoop, print it. +Get(""currentLoop"") > Print; +// Pipeline: read currentLoop, add 1, write back. +Get(""currentLoop"") > HelperFuncAdd(_, 1) > Set(""currentLoop"", _); +// Pre-compute the branch condition (compare guessNum with targetNum, branch). +vaaa0001 = HelperFuncCompare(""BEQ"", guessNum, targetNum); +NextBlock = Branch(vaaa0001, ""SuccessLogic"", ""CheckLogic""); #Block CheckLogic -NextBlock = Branch( - HelperFuncCompare(""BLT"", guessNum, targetNum), - ""LessThanLogic"", - ""GreaterThanLogic"" -); +vaaa0001 = HelperFuncCompare(""BLT"", guessNum, targetNum); +NextBlock = Branch(vaaa0001, ""LessThanLogic"", ""GreaterThanLogic""); #Block LessThanLogic Print(""Too small"");