C# (Roslyn) — типизированные смарт-скрипты¶
Все доступные Smart Actions с сигнатурами — MCP-серверы
/mcp-actions-*(21 группа, ~205 действий). Каталог:docs/reference/api/mcp-catalog.md.
Статус¶
Реализовано. Доступно в продакшене с 2026-04-20. SMART API расширен до полноты 2026-04-21.
Обзор¶
Пятый язык смарт-скриптов. Runtime — Roslyn Scripting API (Microsoft.CodeAnalysis.CSharp.Scripting), in-process .NET, без внешних зависимостей. Тот же компилятор что и у ядра Первой Формы.
Ключевое отличие от Lua/JS/OneScript:
- Остальные движки — чужеродные runtime с маршалинг-слоем (JsSmartApi, SmartActionScriptApi, …).
- C#-скрипт — тот же C#, что и платформа. Прямой доступ к IContext, сервисам, репозиториям. Zero marshaling.
| Аспект | JS (Jint) | C# (Roslyn) |
|---|---|---|
| Типизация | Нет | Полная, compile-time |
| Async | SendHttpRequestAsync + Publication-костыли |
Нативный async/await, Task.WhenAll |
| Маршалинг | JS ↔ CLR через обёртки (10 модулей × ~200 LOC) | Нет — прямой вызов сервисов |
| Ошибки | Runtime (undefined is not a function) |
Compile-time (CS-ошибки до запуска) |
| Холодный старт | ~5 мс | ~100–200 мс (первая компиляция) |
| Горячий запуск | Средний (интерпретация) | Быстрый (IL, нативная скорость после кеша) |
| Sandbox | Встроенный (MaxStatements, timeout) | Trusted MVP (таймаут), sandboxed — Phase 2 |
| Размер рантайма | 0 (Jint — чистый managed) | ~15 МБ (Roslyn managed, уже в проекте) |
Версионирование — обязательно¶
То же правило что и для JS. Каждый C# SmartScript обязан содержать комментарий с версией и датой в начале скрипта:
// v1 | 2026-04-21 10:30 | Начальная версия
// v2 | 2026-04-22 15:45 | Добавлена проверка прав
Архитектура¶
Точка входа¶
SmartScriptService.EvaluateSmartScript / EvaluateSmartScriptAsync — ветка ScriptLanguage.CSharp:
smartScript.Language == CSharp
→ EvaluateCSharpSmartScript → RoslynScriptEngine.ExecuteScript(...)
→ CompiledSmartScriptCache (IL-делегат по hash скрипта)
Ключевые классы¶
| Класс | Файл | Роль |
|---|---|---|
RoslynScriptEngine |
TCClassLib/Smart/Scripting/Engine/RoslynScriptEngine.cs |
Основной движок: компиляция, вызов, таймаут |
RoslynScriptCompiler |
TCClassLib/Smart/Scripting/Engine/RoslynScriptCompiler.cs |
Обёртка над CSharpScript.Create + CreateDelegate |
RoslynCompilationOptionsFactory |
TCClassLib/Smart/Scripting/Engine/RoslynCompilationOptionsFactory.cs |
Сборка ScriptOptions: whitelist сборок, imports по умолчанию, runtime-расширения |
CompiledSmartScriptCache |
TCClassLib/Smart/Scripting/Engine/Caching/CompiledSmartScriptCache.cs |
Кеш IL-делегатов по hash-у текста скрипта |
SmartScriptGlobals |
TCClassLib/Smart/Scripting/Engine/SmartScriptGlobals.cs |
Контекст скрипта: public-члены становятся глобальными именами |
CSharpSqlApi/HttpApi/CacheApi/UtilsApi/LibApi/LogApi/SmartApi |
TCClassLib/Smart/Scripting/Engine/CSharpApi/*.cs |
API-обёртки (тот же паттерн что Jint — для единообразия имён) |
CSharpFilesApi/MetaApi/RegistryApi/VectorDbApi |
TCClassLib/Smart/Scripting/Engine/CSharpApi/*.cs |
Дополнительные API-обёртки: файловые операции, метаданные ДП, реестр сущностей, векторный поиск |
ScriptLanguage |
Valhalla.Integration/Enums/Smart/ScriptLanguage.cs |
Enum: CSharp = 4 |
Компиляция, кеш и память¶
Разделение компиляции и выполнения. На этапе компиляции из текста скрипта строится IL-делегат через CSharpScript.Create + CreateDelegate — это дорогая операция (CPU-bound, ~100–200 мс на холодный запуск, до 1–3 с на крупный скрипт ~200 КБ). На этапе выполнения готовый делегат вызывается с подготовленным SmartScriptGlobals — это уже быстро (нативная скорость IL).
Компиляция кешируется. CompiledSmartScriptCache хранит собранный IL-делегат по ScriptID (на персистентные скрипты) и в bounded LRU (на ad-hoc / черновики из редактора). При повторном запуске того же скрипта компиляция не повторяется.
Когда происходит компиляция¶
| Сценарий | Что происходит |
|---|---|
| Первый запуск персистентного скрипта после старта пула | Компиляция, результат кладётся в CompiledSmartScriptCache по ScriptID |
| Повторный запуск того же скрипта | Cache hit — компиляция не повторяется |
Сохранение скрипта в редакторе (Save) |
На ноде-инициаторе выполняется компиляция, кеш по ScriptID обновляется. На остальных нодах кластера — только удаление записи из кеша (см. инвалидацию ниже), новая версия компилируется при первом следующем запуске |
Запуск несохранённого черновика из редактора (scriptId == 0 или текст не совпадает с persisted) |
Поиск в ad-hoc LRU-кеше по SHA-256 от (scriptId | scriptName | code). Cache miss → компиляция и запись в LRU; cache hit → используется готовый делегат |
LIB.Include("name") / Include(id) внутри скрипта |
Тот же кеш по ScriptID библиотечного скрипта; первый Include после инвалидации компилирует |
Инвалидация кеша в кластере¶
При сохранении или удалении SmartScript на одной ноде остальные ноды должны узнать, что кешированная компиляция устарела. Это делает CompiledSmartScriptCache.InvalidateByEvent: на удалённых нодах из кеша только удаляется запись (Provider.Remove), без немедленной перекомпиляции. Перекомпиляция выполняется лениво — на следующем Get для этого ScriptID.
Почему так. Эвент инвалидации может прилететь на каждой ноде сразу после массового деплоя (cutover, обновление набора скриптов). Если бы каждая нода в ответ на эвент сразу компилировала все скрипты, на которые пришли инвалидации, это создало бы шквал параллельных компиляций по всему кластеру. Lazy-схема растягивает стоимость по времени: компиляция платится только тогда, когда конкретный скрипт реально кому-то понадобился.
Защита от пиковой нагрузки на компиляцию¶
Компиляция Roslyn — синхронная CPU-bound операция; параллельный массовый запуск (например, cold-cache cascade agent-loop + LIB.Include для ~38 модулей подряд) даёт пиковое потребление managed-памяти и нагрузку на ASP.NET worker pool. На уровне RoslynScriptCompiler.Compile стоит ограничитель параллельных компиляций — SemaphoreSlim(2, 2). Не более двух компиляций одновременно в процессе; остальные ждут в очереди.
Это защищает от двух проблем сразу: (1) суммарного объёма transient-аллокаций (SyntaxTree, IL emit buffer, MetadataReferences) от N параллельных компиляций → Gen2 GC stop-the-world паузы; (2) contention на CLR loader lock при параллельных Assembly.Load после Emit.
На cold-cache cascade семафор не убирает блокировку первого пользователя (он всё равно последовательно компилирует все зависимости через LIB.Include), но удерживает процесс в живом состоянии — app pool остаётся отзывчивым на остальные запросы.
Лог компиляции¶
Для диагностики и замеров есть AutomationScriptsLogService.Log с замером времени Compile и ValidateCompilation. Полезен при разборе медленных деплоев и подозрений на cold-cascade.
Утечка памяти при частом редеплое (известное ограничение)¶
Каждая компиляция создаёт новую динамическую сборку и грузит её в AssemblyLoadContext.Default, который не unloadable. На проде эффект практически не заметен — скрипты редко редеплоятся. На активной dev-сессии (частый Save / Run черновика / редеплой через cutover) за 1–2 часа процесс накапливает сотни Roslyn submission-сборок и MALLOC native heap может разрастись до 17+ ГБ. Маркер в dotnet-gcdump: модули ℛ*<guid>#<N>-0 с растущим counter #377, #378, #379...
Частичные митигации, уже применённые:
- Hash-cache на ad-hoc path.
RoslynScriptEngine.ResolveRunnerдляscriptId == 0или несохранённого текста использует bounded LRU (32 entry) с ключом SHA-256 от(scriptId | scriptName | code). Повторные «Run» одного черновика из редактора не создают новую сборку. - Удаление поля
ScriptизCompiledSmartScript: после получения runner'а сам объектScript<object>больше не удерживается (экономия ~1–3 МБ managed на скрипт). - Сабпроцесс-компиляция тяжёлых сценариев.
RoslynScriptCompiler.CompileViaSubprocessAsyncзапускает консольное приложениеSmartScriptCompileHelper(отдельный .NET-процесс): компиляция выполняется в нём, помапленный IL передаётся обратно. Память компилятора освобождается с завершением субпроцесса. В свойствах проектаSmartScriptCompileHelper.csprojобязателенfalseдляUseAppHost— иначе деплой хелпера падает.
Полный архитектурный фикс — collectible AssemblyLoadContext per-script + Unload на инвалидации — требует отказа от Microsoft.CodeAnalysis.Scripting.CSharpScript.Create (он жёстко грузит submission в default ALC) и перехода на сырой CSharpCompilation + ручную обёртку script body в обычный класс. Объём — 1–2 недели; на момент написания статьи не применён, ведётся как отдельная инициатива.
Для разработчика на dev-стенде это означает: при длительной сессии и большом числе редеплоев SS наблюдать за рабочей памятью процесса — при росте выше 10–12 ГБ перезапускать пул.
Минимальный скрипт¶
// v1 | 2026-04-23 12:00 | hello world
LOG.AddAutomationLog("hello from C#");
RESULT = new { ok = true, message = "hello" };
Verified on dev 2026-04-23 — полный пример: probes/hello-csharp-probe.cs.
RESULT — Lua/JS-совместимый escape hatch. Нативный вариант — return:
return new { ok = true, message = "hello" };
Приоритет: нативный return выигрывает у RESULT. RESULT используется только если runner вернул null.
SmartScriptGlobals — что доступно в скрипте¶
Все public-члены класса SmartScriptGlobals — глобальные имена в скрипте. Не надо ничего импортировать.
// Execution context
IContext Context // сессия, userId, кэш
ILifetimeScope Scope // Autofac scope — для ad-hoc Resolve<T>()
IReadOnlyDictionary<object,object> EVENTPARAMS // параметры события
object SESSION_USER // текущий пользователь
string DB_TYPE // "MSSQL" | "PG"
object SYSTEM_INFO
// Platform API (те же имена что в JS/Lua — для консистентности)
CSharpSqlApi SQL
CSharpHttpApi HTTP
CSharpCacheApi CACHE
CSharpUtilsApi UTILS
CSharpLibApi LIB
CSharpLogApi LOG
CSharpSmartApi SMART
CSharpFilesApi FILES
CSharpMetaApi META
CSharpRegistryApi REGISTRY
CSharpVectorDbApi VECTOR_DB
// Параметры события (паритет с JS top-level globals)
IReadOnlyDictionary EXTRAPARAMS
// SSE streaming для агентских/потоковых сценариев
Action STREAM_EMIT
Func STREAM_CANCELLED
// Escape hatch для Lua/JS-стиля (когда нативный return неудобен)
object RESULT { get; set; }
// Output для редактора и логов
void print(object message)
DI-шлюз для продвинутых случаев:
// Резолв произвольного сервиса из Autofac-скоупа
var provider = Resolve<IHostingFacade>();
// Property-injection в собственные классы (декларируем в скрипте)
public class MyHelper {
public IDbService Db { protected get; set; }
public int CountTasks() =>
Convert.ToInt32(Db.GetScalarQueryResult<object>("SELECT COUNT(*) FROM Tasks"));
}
var helper = InjectProperties(new MyHelper());
var n = helper.CountTasks();
SMART API¶
Полный паритет с JS SMART API — те же методы, типизированные.
// Выполнить action
object ExecuteAction(string action, int? contextId, string contextType,
Dictionary<string, object> @params = null, bool async = false)
object ExecuteActionInTaskContext(string action, int? contextTaskId, ...)
object ExecuteActionInUserContext(string action, int? contextUserId, ...)
object ExecuteActionInEmailContext(string action, int? contextEmailId, ...)
// Запустить SmartScript в фоне (fire-and-forget, RESULT теряется)
void RunScriptBackground(object smartScriptIdOrName, int? contextId = null,
object[] eventParams = null, Dictionary<string, object> extraParams = null,
bool ignoreScriptEvent = false)
// Запустить SmartScript синхронно с возвратом RESULT
object RunScriptSync(object smartScriptIdOrName, int? contextId = null,
string contextType = "task", object[] eventParams = null,
Dictionary<string, object> extraParams = null, bool ignoreScriptEvent = false)
contextType: "task"|"user"|"email"|"edocumentlink"|"edocumentlinksbis". Default "task".
RunScriptSync — блокирующий вызов с корректным awaits async-скриптов. Каскад ограничен 3 уровнями (SyncScriptCallGuard). Типизированный RPC между SmartScript'ами: caller на C# может объявить record для RESULT и сериализовать/десериализовать без JSON.parse-костылей.
// Пример: caller вызывает job-скрипт, получает типизированный результат
var raw = SMART.RunScriptSync("anfisa-job", taskId, "task", null,
new Dictionary<string, object> { ["Prompt"] = "суммаризируй", ["UserId"] = 28 },
ignoreScriptEvent: true);
var result = JsonConvert.DeserializeObject<JobResult>(JsonConvert.SerializeObject(raw));
return new { ok = result.Ok, answer = result.Answer };
public record JobResult(bool Ok, string Answer, int TokensUsed);
Когда что использовать:
| Нужно | Метод |
|-------|-------|
| Выполнить SS в фоне, RESULT не нужен | SMART.RunScriptBackground |
| Получить RESULT синхронно, передать extraParams | SMART.RunScriptSync |
| Выполнить в том же scope (shared globals) | LIB.Include(name) / Include(id) |
| Выполнить action (не SmartScript) | SMART.ExecuteAction* |
SettingsCustom API (после MR !1261)¶
Типизированный доступ к CustomSettingsCache через Resolve<T>() — не через SQL. SQL-обход на SettingsCustom — антипаттерн: обходит кэш (1h TTL), превращает миграцию на C# в другой костыль.
Рабочий паттерн (v2, verified локально 2026-04-23)¶
// Одна инициализация в начале скрипта
var _csCache = Resolve<TCDataAccess.Caching.InMemory.CustomSettingsCache>();
// Helper — обёртывает cold-cache quirk (см. ниже)
string GetSettingValue(string key)
{
var k = new Valhalla.Integration.Dto.Settings.CustomSettingKey(key, null);
var entity = _csCache.Get(k) ?? _csCache.Invalidate(k);
return entity?.Value;
}
// User-scope вариант
string GetSettingValueForUser(string key, int userId)
{
var k = new Valhalla.Integration.Dto.Settings.CustomSettingKey(key, userId);
var entity = _csCache.Get(k) ?? _csCache.Invalidate(k);
return entity?.Value;
}
// Использование
var apiKey = GetSettingValue("anfisa_anthropic_key");
var userTheme = GetSettingValueForUser("user_theme", 28);
Cold-cache quirk (важно для non-prod стендов)¶
CustomSettingsCache имеет InvalidateIfGetIsEmpty = false — при первом Get(key) без прогрева возвращает null без lookup в БД. В прод-ядре кэш прогревается через API-записи (POST /api-core/data-source-form/72/action → Invalidate ключа), поэтому Get(key) обычно уже горячий.
На jobs-less стендах (локалка, часть dev-стендов где IsMainServer=false) кэш холодный — Get(key) возвращает null даже если запись в БД есть. Fallback Get ?? Invalidate делает force-load из БД при пустом кэше и сам кэширует — один лишний DB round-trip только на первом обращении.
Что НЕ делать¶
| ❌ | ✅ |
|---|---|
SQL.scalar("SELECT Value FROM SettingsCustom WHERE Key=@k", ...) |
GetSettingValue("key") |
UTILS.resolve_instance("TCDataAccess", "...CustomSettingsCache") (JS-паттерн) |
Resolve<CustomSettingsCache>() |
Цикл с повторными Resolve<CustomSettingsCache>() на каждый Get |
Один раз в начале + переиспользовать _csCache |
Follow-up (не блокер)¶
Task частично закрыта MR !1261 (расширение TrustedAssemblies). Оставшийся nice-to-have: typed-wrapper GetSetting(key) прямо в SmartScriptGlobals чтобы скрипт писал GetSetting("key") без local helper'а. Ценность: короче + контролируемая поверхность для будущего sandbox Phase 2 (сейчас скрипту виден весь TCDataAccess после MR !1261). Исходная спека (архивная): csharp-local-devstack/spec-smartscriptglobals-typed-accessors.md.
SQL API¶
Имена методов snake_case — симметрично JsSqlApi (чтобы порт JS↔C# шёл один-в-один). Все возвращают object (не generic), типизируй через Convert.ToXxx или cast.
// object scalar(string sql, IDictionary<string, object> params)
var count = Convert.ToInt32(SQL.scalar(
"SELECT COUNT(*) FROM Tasks WHERE Performer = @id",
new Dictionary<string, object> { ["id"] = 28 }));
// object query(string sql, IDictionary<string, object> params) → List<Dictionary<string, object>>
var rows = SQL.query("SELECT TaskID, TaskText FROM Tasks WHERE Performer = @id",
new Dictionary<string, object> { ["id"] = 28 });
// Каждая строка — Dictionary<string, object>. Парсить вручную или прогнать через JsonConvert:
var typed = JsonConvert.DeserializeObject<List<TaskRow>>(JsonConvert.SerializeObject(rows));
// object query_one(string sql, params) — первая строка или null
var one = SQL.query_one("SELECT TaskText FROM Tasks WHERE TaskID = @id",
new Dictionary<string, object> { ["id"] = 2078773 });
// int exec(string sql, params) — для INSERT/UPDATE/DELETE, возвращает affected rows
var affected = SQL.exec("UPDATE Tasks SET Priority = @p WHERE TaskID = @id",
new Dictionary<string, object> { ["p"] = 3, ["id"] = 2078773 });
public record TaskRow(int TaskID, string TaskText);
Почему не generic scalar<int>(): CSharpSqlApi зеркалит контракт JsSqlApi для единообразия имён при портировании. Для типизации используй Convert.ToXxx или явный cast. Если хочется generic-API — резолви TCMain.DB напрямую через Resolve<IDbService>() + используй GetScalarQueryResult<T>(sql, params).
DB-агностичность обязательна (как для всех языков SS). CSharpSqlApi не делает автоматической подмены MS↔PG — пиши DB-совместимый SQL или используй SQL Dialect Helpers (см. ниже). Нельзя raw [Key], GETDATE(), LEN(), ISNULL(), TOP N, N'...' без помощников.
SQL Dialect Helpers¶
Спека: docs/projects/core/sql-dialect-helpers-spec.md. Реализация (2026-04-28): TCDataAccess/Kernel/Domain/SqlDialect.cs + CSharpSqlApi.
SQL.* методы возвращают готовый SQL-фрагмент для подстановки в $"...". Один источник правды (SqlDialect) — формула не повторяется ни в core, ни в SS, ни в трёх местах сразу. Идентификаторы для PG автоматически приводятся к lowercase (SQL.Qi("Value") → "value").
// Пагинация (предпочитай ANSI-вариант — одинаковый фрагмент для MS/PG):
var sql = $@"SELECT TaskID FROM Tasks WHERE Performer = @id
ORDER BY TaskID DESC
{SQL.FetchFirst(10)}";
// Идентификатор + IfNull + текущее время:
var sql2 = $@"SELECT {SQL.Qi("Value")}, {SQL.IfNull("Comment", "''")}, {SQL.Now()} FROM SettingsCustom";
// Lateral / APPLY (парный с LateralOn):
var sql3 = $@"SELECT t.TaskID, c.Color
FROM Tasks t
{SQL.OuterApply()} dbo.fn_TaskColor(t.TaskID, @userId) c {SQL.LateralOn()}
WHERE t.OwnerID = @userId";
// INSERT … OUTPUT/RETURNING (парный):
var newId = Convert.ToInt32(SQL.scalar($@"
INSERT INTO Logs (Msg) {SQL.OutputInsertedId("Id")}
VALUES (@m) {SQL.ReturningId("Id")}",
new Dictionary<string, object> { ["m"] = "test" }));
// DateAdd с типом единицы (предпочтительный вариант):
var sql4 = $"SELECT * FROM Tasks WHERE Created > {SQL.DateAdd(SqlDateUnit.Hour, -2)}";
// Длинный текст:
var sql5 = $"SELECT {SQL.CastText("Body", 400)} FROM Comments";
Полный список методов¶
| Группа | Метод | MS | PG |
|---|---|---|---|
| Идентификаторы | Qi(name) |
[name] |
"name" (lowercase!) |
| Время | Now() / NowFn() |
GETDATE() |
NOW() / now() |
| UUID | NewId() |
NEWID() |
gen_random_uuid() |
| Пагинация | Top(n) |
TOP n |
`` |
Limit(n) |
`` | LIMIT n |
|
FetchFirst(n) |
OFFSET 0 ROWS FETCH NEXT n ROWS ONLY |
то же | |
| Hint | Nolock() |
WITH (NOLOCK) |
`` |
| Скаляры | IfNull(e, d) |
ISNULL(e, d) |
COALESCE(e, d) |
Len(e) |
LEN(e) |
LENGTH(e) |
|
Concat(a, b, ...) |
a + b + ... |
a \|\| b \|\| ... |
|
| Даты | DateAdd(SqlDateUnit, amount, baseExpr=null) |
DATEADD(UNIT, amount, base) |
(base ± INTERVAL 'amount units') |
DateAddParam(SqlDateUnit, "@p", baseExpr=null) |
DATEADD(UNIT, @p, base) |
(base + (@p) * INTERVAL '1 unit') |
|
DateDiffSeconds(a, b) |
DATEDIFF(SECOND, a, b) |
EXTRACT(EPOCH FROM (b - a)) |
|
FormatDate(e, SqlDateFormat) |
CONVERT(VARCHAR(N), e, style) |
to_char(e, 'pattern') |
|
| Касты | CastText(e, maxLen?) |
CAST(e AS NVARCHAR(maxLen\|MAX)) |
CAST(e AS TEXT) (или LEFT(...)) |
CastVarchar(e) |
e |
e::varchar |
|
CastInt(e) |
CAST(e AS INT) |
e::integer |
|
CastUuid(e) |
e |
e::uuid |
|
| LATERAL | CrossApply() |
CROSS APPLY |
CROSS JOIN LATERAL |
OuterApply() |
OUTER APPLY |
LEFT JOIN LATERAL |
|
LateralOn() |
`` | ON TRUE |
|
| Recursive CTE | WithRecursive(name) |
WITH name |
WITH RECURSIVE name |
RecursiveKeyword() |
`` | RECURSIVE |
|
| INSERT | OutputInsertedId(col) |
OUTPUT inserted.col |
`` |
ReturningId(col) |
`` | RETURNING col |
SqlDateUnit: Second, Minute, Hour, Day, Month, Year.
SqlDateFormat: Iso (yyyy-MM-dd HH:mm:ss), IsoDate (yyyy-MM-dd), RuDate (dd.MM.yyyy), RuDateTime (dd.MM.yyyy HH:mm:ss).
Совместимость со старым API¶
Существующие методы SQL.Qi, SQL.QuoteIdent, SQL.NowFn, SQL.IfNull, SQL.CastText(expr), SQL.DateAdd(string part, ...), SQL.NewId оставлены без изменений — старые скрипты продолжают работать. Новый код используй через таблицу выше; для DateAdd предпочитай SqlDateUnit-перегрузку.
Те же хелперы в core¶
В прикладном коде ядра доступны через extension methods на IValhallaDataConnection (IValhallaDataConnection.Extensions.cs, db.IsPostgreDB) и на IDBHandler (IDBHandlerSqlDialectExtensions.cs, TCMain.DB.IsPostgre). Все три точки входа делегируют на единый SqlDialect.
HTTP API¶
Имена методов тоже snake_case (паритет с Jint):
// object send_http_request(method, url, params, headers, rawBody, options)
// или async-версия:
var resp = await HTTP.send_http_request_async("POST", "https://...",
@params: null,
headers: new Dictionary<string, object> { ["Authorization"] = "Bearer …" },
rawBody: JsonConvert.SerializeObject(new { q = "test" }),
options: new Dictionary<string, object> { ["Timeout"] = 30000 });
// Типизированный альтернативный путь:
var typed = await HTTP.post_async("https://...", new ScriptHttpRequestOptions {
RawBody = JsonConvert.SerializeObject(new { q = "test" }),
Timeout = 30000
});
// typed — ScriptHttpResponse, с полями .StatusCode, .Body, .Headers
Полный паритет с Jint-версией для snake_case; дополнительно — типизированный request/get/post/request_async для удобства C#.
CACHE API (ScriptDataCache — shared с JS/Lua)¶
Snake_case, паритет с JsCacheApi. Кэш процесс-локальный, shared между языками SS.
CACHE.set("anfisa:sess:123", "payload", lifetime: 3600); // lifetime в секундах
var v = CACHE.get("anfisa:sess:123"); // → string или null
CACHE.delete("anfisa:sess:123");
Используй для кэша внутри одного SS или share между SS. Не для SettingsCustom — там свой кэш через CustomSettingsCache (см. §SettingsCustom API).
UTILS API — минимально¶
CSharpUtilsApi намеренно тонкий. В C# полный BCL доступен напрямую, не нужно wrapper'ов для стандартных вещей.
// Всё что есть в CSharpUtilsApi
UTILS.Print("progress"); // → output-буфер, виден в /editor/execute response
var empty = UTILS.IsEmpty(value); // null / "" / IEnumerable с Count=0 → true
// Глобальный shortcut: print(msg) на SmartScriptGlobals = UTILS.Print без префикса
print("hello");
Что использовать вместо UTILS.* из JS¶
| JS | C# |
|---|---|
UTILS.new_guid() |
Guid.NewGuid().ToString() |
UTILS.json_decode(s) |
JsonConvert.DeserializeObject<T>(s) или JObject.Parse(s) |
UTILS.json_encode(o) |
JsonConvert.SerializeObject(o) |
UTILS.trim_to_tokens(text, n) |
нет стандартного аналога — писать руками по нужде |
UTILS.resolve_instance(asm, type) |
Resolve<T>() (typed) или Scope.Resolve(Type.GetType($"{type}, {asm}")) (reflection fallback) |
UTILS.create_instance(asm, type) |
new T(...) (typed) или Activator.CreateInstance(Type.GetType(...)) |
LOG API¶
Единственный метод, паритет с JsLogApi.AddAutomationLog:
LOG.AddAutomationLog("heartbeat: alive");
LOG.AddAutomationLog("failed", parameters: $"ex={ex.Message}, stack={ex.StackTrace}");
Пишет в AutomationScriptsLog (form 91). objectKey = ID текущего SS, objectName = Description. В JS было SS.logError/SS.logInfo — в C# один метод, разделения по severity нет (использовать префикс в message если нужно: "[ERROR] ...").
Import / Include / using — четыре разных механизма¶
В C# SS есть четыре способа «подключить что-то» — с разной семантикой. Не путать.
| Форма | Пример | Что даёт |
|---|---|---|
using Namespace; |
using System.Text.RegularExpressions; |
Стандартный C#-импорт пространства имён. Требует что сборка содержащая этот namespace уже в TrustedAssemblies (или TrustedImports уже содержит namespace) |
#r "Assembly" |
#r "System.Xml" |
Roslyn script-directive — добавить сборку в references на уровне конкретного скрипта. Verified работает: #r "System.Xml"; using System.Xml; var d = new XmlDocument(); компилируется без модификации TrustedAssemblies. Полезно для редко используемых BCL-сборок |
#load "file.csx" |
#load "helper.csx" |
Не работает — default-resolver ищет в файловой системе, а у нас скрипты в БД (CS1504 при попытке). Для подключения другого скрипта использовать Include(name) |
Include(name\|id) / SMART.RunScriptSync(...) |
await Include("my-lib") |
Наш механизм кросс-скриптовых вызовов — см. ниже |
Важный сигнал про #r¶
#r "AssemblyName" работает с стандартными .NET сборками в GAC/runtime (типа System.Xml, System.IO.Compression). Для custom сборок платформы путь только через TrustedAssemblies или SmartScriptRoslynAdditionalAssemblies — #r "TCDataAccess" не даст runtime-обход, т.к. это не GAC-resolvable имя.
Это полезно знать: для BCL-сборок которые не в текущем TrustedAssemblies можно взять через #r один раз в конкретном скрипте вместо модификации ядра. Но для платформенных сервисов — только нормальный путь (trusted + Resolve<T>).
Include / Кросс-скриптовые вызовы¶
Два разных механизма с разной семантикой. Не путать.
Include(name|id) — библиотечный вызов, shared SmartScriptGlobals¶
var result = await LIB.Include("helper-script-name"); // by Description
var result = await LIB.Include(642); // by Id
var result = await Include("helper"); // shortcut на SmartScriptGlobals
Что это: включить C#-SS как библиотеку. SmartScriptGlobals (в т.ч. RESULT, SQL, HTTP, CACHE, Context) shared с caller. Target компилируется один раз (в CompiledSmartScriptCache), runner вызывается на тех же globals что и caller.
Требования к target:
- IsLibrary = true — иначе TCLogicException("Скрипт ... не является библиотечным")
- LanguageId = 4 (CSharp) — cross-language JS ↔ C# не поддерживается в MVP (TCLogicException("Cross-language include is not supported in MVP"))
- Существует (искомый по name/id) — иначе EntityNotFoundException / TCLogicException("не найден")
Возвращает: значение return из target (или null если target без return). Тип Task<object>, на runtime это число/string/anonymous record/Dictionary.
Что действительно shared vs изолировано¶
Эксперимент 2026-04-23 на локалке:
| Что | Shared? |
|---|---|
SmartScriptGlobals.RESULT |
✅ да — target может перезаписать, caller увидит |
SmartScriptGlobals.SQL/HTTP/CACHE/... |
✅ да — тот же instance |
Context, Scope |
✅ да |
Локальные переменные target (var libraryVariable = 10;) |
❌ нет — разные Roslyn submissions, CS0103 при попытке доступа |
Иллюстрация:
// caller
RESULT = "caller-initial";
var retVal = await Include("lib"); // target внутри: RESULT = "from lib"; return "ret val";
// После Include:
// retVal = "ret val"
// RESULT = "from lib" (библиотека перезаписала!)
Вывод: использовать library как чистые функции с return-value, не полагаться на shared state через RESULT (если явно не нужно).
Рекурсия / цепочки¶
- Loop detection: если один script id встречается в стеке 5 раз подряд —
TCLogicException("Рекурсия в include() глубже 5: <trace>"). Именно loop (same id), не numeric depth — линейная цепочка A→B→C→D→E→F→... не упадёт по этому правилу. - Таймаут выполнения — 15 минут default (
RoslynScriptEngine.DefaultTimeoutMinutes). Линейная цепочка упрётся сюда если скрипты медленные.
SMART.RunScriptSync(id|name, ...) — типизированный RPC, child scope¶
Альтернативный механизм из MR !1219+!1239:
var raw = SMART.RunScriptSync(
"anfisa-job", // id или name
contextId: taskId,
contextType: "task", // task|user|email|...
eventParams: null,
extraParams: new Dictionary<string, object> {
["Prompt"] = "суммаризируй",
["UserId"] = 28
},
ignoreScriptEvent: true);
// raw — это RESULT target'а; если нужно типизированно, десериализуй:
var typed = JsonConvert.DeserializeObject<JobResult>(JsonConvert.SerializeObject(raw));
Отличия от Include:
| Aspect | Include(name) |
SMART.RunScriptSync(name, ...) |
|---|---|---|
| Язык target | C# only | любой (JS, Lua, OneScript, C#) |
IsLibrary=true обязательно |
✅ да | ❌ нет |
| Scope | Shared SmartScriptGlobals (caller RESULT перезаписывается!) | Child scope через IocContainer.StartChildScopeInUserContext — изолированный DI, свой SmartScriptGlobals |
| Parameters | Нет — через shared globals | eventParams + extraParams dict (→ EXTRA_PARAMS в target) |
| Return value | return значение |
target'а RESULT или return |
| Async поведение | Нативный await Include(...) |
Блокирующий .GetAwaiter().GetResult() внутри (но caller C# может писать var x = SMART.RunScriptSync(...) без await) |
| Recursion | Loop-detection @ 5 (same script id) | Numeric depth SyncScriptCallGuard @ 3 |
| Use case | Cookbook-helpers, shared SQL-хелперы, DB-agnostic helpers | Typed RPC между модулями, cross-language bridge (caller C#, target JS), изоляция ошибок |
Когда что выбирать¶
Include — когда:
- Target — чистая утилита, написанная на C#, переиспользуется несколькими SS
- Нужна скорость (no child scope overhead)
- Готов к тому что target может перезаписать RESULT
RunScriptSync — когда:
- Target на другом языке (JS → хочешь вызвать из C# heartbeat, например)
- Нужна изоляция (child scope + own globals)
- Нужны параметры через EXTRA_PARAMS
- Хочешь typed RPC контракт caller → JobResult record
RunScriptBackground — когда: - Fire-and-forget (не нужен результат) - Delegation субагентов, mailbox patterns - Не блокировать caller
Cross-language матрица (проверено эмпирически 2026-04-23)¶
| From → To | Include / include() |
SMART.run_script_sync / SMART.RunScriptSync |
|---|---|---|
| JS → JS | ✅ | ✅ |
| JS → C# | ❌ Jint парсит C# как JS (Unexpected token) |
✅ C# anonymous record → JS object |
| C# → JS | ❌ Cross-language include is not supported in MVP |
✅ JS object → C# ExpandoObject (dynamic) |
| C# → C# | ✅ shared scope | ✅ child scope |
Правило: Include/include() — строго same-language. Для cross-language — SMART.run_script_sync (bridge-pattern между всеми 5 языками SS).
Миграционная стратегия для массового порта JS → C¶
Cross-language interop через run_script_sync означает что миграция не блочит интеграцию:
| Место в JS-коде | Новая стратегия при миграции этого скрипта на C# |
|---|---|
include("x-js-lib") где x-js-lib тоже мигрируем |
Замена на await Include("x-js-lib") (оба на C#, shared scope) |
include("x-js-lib") где target остаётся JS |
Замена на SMART.RunScriptSync("x-js-lib", null, "task", null, null, true) (child scope) |
SMART.run_script_background("y-js", ...) |
SMART.RunScriptBackground("y-js", ...) — cross-language тоже работает (fire-and-forget) |
Наоборот — JS caller может вызывать новые C# хелперы через SMART.run_script_sync до того как сам caller будет мигрирован. Это ключ постепенной миграции Анфисы: Tier-1 модули переходят на C# по одному, не ломая существующие JS-вызовы.
Компиляция и кеш¶
Первый вызов — Roslyn компилирует скрипт в IL, ~100–200 мс. Последующие вызовы — берут готовый делегат из CompiledSmartScriptCache (ключ — hash текста скрипта), выполнение на скорости обычного C#.
Invalidation: при изменении текста SS (новый hash) — компилируется заново, старый делегат живёт до вытеснения по LRU.
ScriptOptions — что доступно «из коробки» (после MR !1261, 2026-04-23)¶
Сборки (whitelist) — 12 assemblies:
| Assembly | Кому/зачем |
|---|---|
System.Private.CoreLib |
object, всё System.* primitive |
System.Linq |
LINQ — Enumerable, Queryable, Where/Select/… |
System.Threading.Tasks |
Task, async/await, Task.WhenAll |
System.Net.Http |
HttpClient (если нужен bypass HTTP API) |
Newtonsoft.Json |
JsonConvert, JObject |
Valhalla.Interfaces / TCInterfaces |
IContext, IDependency, сигнатуры |
TCClassLib |
SmartScriptGlobals + все CSharpApi/* |
Valhalla.Integration |
ScriptLanguage, CustomSettingKey, DTO |
TCDataAccess (+2026-04-23) |
CustomSettingsCache, UsersCache, CategoriesCache, SubcategoriesCache, etc — все кэши, сервисы сущностей |
| Utils assembly (+2026-04-23) | Общие helpers (TypeExtensions и др.) |
| TCClassLib.Orm (+2026-04-23) | TcContext (EF) — прямая работа с ORM, Tasks/Users/Comments DbSet |
Valhalla.ExternalServices (+2026-04-23) |
ExternalServicesConfigurationServiceBase<,> и т.п. |
Microsoft.CSharp |
Поддержка dynamic keyword и runtime-биндинга |
Imports по умолчанию (не надо писать using):
System
System.Linq
System.Threading.Tasks
System.Collections.Generic
Newtonsoft.Json
System.Text (+2026-04-23 — StringBuilder, Encoding)
TCClassLib.Smart.Scripting.Engine
Источник: core/TCClassLib/Smart/Scripting/Engine/RoslynCompilationOptionsFactory.cs:147.
Runtime-расширения — администратор может добавить дополнительные сборки через SettingsCustom.SmartScriptRoslynAdditionalAssemblies (CSV путей). Ограничено директорией {AppDir}/SmartScriptExtensions/ — чтобы нельзя было подгрузить произвольный DLL с диска.
Sandbox¶
MVP (trusted mode) — таймаут + whitelist сборок. Default-таймаут 15 минут (DefaultTimeoutMinutes в RoslynScriptEngine), настраивается через CancellationToken.
Sandboxed mode — Phase 2 (не реализован): syntax walker запрещает System.IO, System.Diagnostics, System.Reflection.Emit, Assembly.Load*, Activator.CreateInstance, Process.Start, File.*, Environment.*. Нужен когда клиенты/внедренцы получат возможность писать C#-скрипты.
Локальный pre-deploy линт¶
ss-csharp-lint — CLI-тулза для проверки C#-SS локально без деплоя. Использует ту же Roslyn-компиляцию что рантайм (RoslynCompilationOptionsFactory), ловит CS-ошибки до deploy-batch.py. Статус: спека (2026-04-23), в разработке.
До готовности CLI — альтернативы:
- Скомпилировать файл в core/ как часть solution и проверить через dotnet build.
- Создать unit-тест в Tests/TCClassLib.Tests.Unit.Core/Smart/Scripting/CSharp/ по образцу JsSmartApiRunScriptSyncTests.cs.
Диагностика runtime-ошибок¶
| Симптом | Где смотреть |
|---|---|
CS**** compile error |
Ответ SmartScriptEditorService.Execute / AutomationScriptsLog |
| Таймаут | OperationCanceledException в AutomationScriptsLog |
TCLogicException("Превышена максимальная глубина синхронных вызовов SmartScript (3)") |
Каскад SMART.RunScriptSync → подрезать глубину, использовать RunScriptBackground для async-путей |
Неожиданный null от RunScriptSync |
Target не выставил RESULT и не сделал return value. Проверить target-скрипт |
Когда писать на C#, а когда — на JS¶
| Критерий | JS (Jint) | C# (Roslyn) |
|---|---|---|
| Много string manipulation, JSON-шафл | ✅ | ─ |
| Хот-итерация (промпты, persona routing, exprimental paths) | ✅ | ─ |
| Короткий (<100 LOC) | ✅ | ─ (typing overhead не амортизируется) |
| Часто меняющая shape логика | ✅ | ─ (типы приходится двигать вместе с логикой) |
| Типизированные контракты (LLM response, mailbox) | ─ | ✅ |
Async/параллельность (Task.WhenAll) |
─ | ✅ |
| Прямой DI к сервисам платформы | ─ | ✅ |
| Сложная state machine | ─ | ✅ |
| 5+ вызовов платформенных сервисов | ─ | ✅ (IntelliSense против галлюцинаций имён) |
Для выбора языка конкретного модуля Анфисы — native-pipeline/README.md, Tier-1 кандидаты.
Pack для делегатов (CLI coders) 📦¶
Этот раздел — self-contained минимум для делегирования C# SS задач в kimi/glm/minimax/qwen/gemini/cursor. Давать делегату ссылку на этот раздел целиком, плюс конкретный task prompt.
1. Файлы-источники правды (прочитать ДО написания кода)¶
Все 5 групп — прочитать целиком. Если сигнатура или поведение в документации расходится с кодом — верить коду, доку обновят потом.
A. Движок и API (обязательно):
- core/TCClassLib/Smart/Scripting/Engine/SmartScriptGlobals.cs — что доступно скрипту
- core/TCClassLib/Smart/Scripting/Engine/RoslynCompilationOptionsFactory.cs — TrustedAssemblies, TrustedImports
- core/TCClassLib/Smart/Scripting/Engine/RoslynScriptEngine.cs — execution, timeout 15 мин
- core/TCClassLib/Smart/Scripting/Engine/CSharpApi/CSharp*.cs — все 7 файлов: Sql, Http, Cache, Utils, Lib, Log, Smart
B. Типы-параметры:
- core/Valhalla.Interfaces/IContext.cs
- core/Valhalla.Integration/Valhalla.Integration/Dto/Settings/CustomSettingKey.cs
- core/Valhalla.Integration/Valhalla.Integration/Dto/Smart/SmartScript/ScriptHttpRequestDto.cs (для ScriptHttpRequestOptions)
C. Living docs (тесты как примеры):
- core/Tests/TCClassLib.Tests.Unit.Core/Smart/Scripting/CSharp/CSharpApiTests.cs — реальная shape dictionary-ответов HTTP
- core/Tests/TCClassLib.Tests.Unit.Core/Smart/Scripting/CSharp/RoslynScriptEngineTests.cs — async/await patterns
D. DDL (per task): DB_MSSQL/dbo.{Table}.Table.sql для каждой таблицы которую SS читает. Имена колонок ≠ документации (см. ловушки ниже).
E. Контекст задачи: source JS SS (если порт), migration plan, рабочий пример probes/hello-csharp-probe.cs.
2. Золотой набор паттернов (cookbook)¶
Шапка скрипта:
// v1-csharp | YYYY-MM-DD | Краткое описание
// Source: <source JS файл если порт, или "new">
// Depends: <зависимости, например "MR !1261 для TCDataAccess">
SettingsCustom:
var _csCache = Resolve<TCDataAccess.Caching.InMemory.CustomSettingsCache>();
string GetSettingValue(string key) {
var k = new Valhalla.Integration.Dto.Settings.CustomSettingKey(key, null);
return (_csCache.Get(k) ?? _csCache.Invalidate(k))?.Value;
}
SQL:
// COUNT/SUM/AVG → scalar + Convert
var n = Convert.ToInt32(SQL.scalar(
"SELECT COUNT(*) FROM Tasks WHERE Performer = @id",
new Dictionary<string, object> { ["id"] = userId }));
// Many rows
var rows = SQL.query("...", new Dictionary<string, object> { ... });
// Каждая строка = Dictionary<string, object>; для типизации — JsonConvert.DeserializeObject
HTTP:
// Dict API (паритет с Jint) — возвращает IDictionary<string, object>
var resp = await HTTP.send_http_request_async("GET", url, null, headers, null, null);
// resp["HttpResponse"]["StatusCode"], resp["InnerError"]
Log:
LOG.AddAutomationLog("progress: step N");
LOG.AddAutomationLog("error", parameters: $"ex={ex.Message}");
DB-agnostic helpers (если нужны):
bool IsPg() => DB_TYPE == "PG";
string NoLock() => IsPg() ? "" : " WITH(NOLOCK)";
string Qi(string col) => IsPg() ? $"\"{col}\"" : $"[{col}]";
Return:
return new { ok = true, result = ..., v = "v1-csharp" }; // нативный C# return
// или RESULT = value; return; — если в стиле Lua/JS
3. Что НЕ делать¶
| ❌ | ✅ | Почему |
|---|---|---|
SQL.scalar("SELECT Value FROM SettingsCustom WHERE Key=@k") |
GetSettingValue(key) через CustomSettingsCache |
Обходит кэш (1h TTL) → DB roundtrip каждый вызов |
Type.GetType(...); Activator.CreateInstance(...) |
Resolve<T>() + new T(...) |
Типизация + compile-time проверка |
UTILS.NewGuid() |
Guid.NewGuid() |
UTILS в C# минимальный, BCL доступен напрямую |
UTILS.JsonDecode(s) |
JsonConvert.DeserializeObject<T>(s) |
То же |
LOG.Log(LogLevel.Info, "...") |
LOG.AddAutomationLog("...") |
Метод один, нет LogLevel |
SQL.ScalarAsync<int>(...) |
Convert.ToInt32(SQL.scalar(...)) |
API snake_case, non-generic |
ssName.logError(...) / SS.logInfo |
LOG.AddAutomationLog |
Нет глобала SS в C# |
Локальные var объявленные вне {} в надежде что увидит Include-target |
Через SmartScriptGlobals.RESULT или параметры RunScriptSync.extraParams |
Разные submissions — scope не shared |
4. Типичные CS-ошибки и их причины¶
| CS-код | Причина | Fix |
|---|---|---|
CS0234 Тип не существует в пространстве имён |
Использован тип из assembly вне TrustedAssemblies | Проверить список §ScriptOptions. Для TCDataAccess типов — MR !1261+ |
CS1061 Тип не содержит определения X |
Неверное имя метода на платформенном API | Читать CSharpApi/*.cs файлы (snake_case vs PascalCase) |
CS4014 Вызов не ожидается (missing await) |
Include(...) / SQL.scalar(...) без await |
Добавить await или явно игнорировать Task |
CS0103 Имя не существует в текущем контексте |
Попытка доступа к переменной из другого submission (library vs caller) | Либо shared через RESULT, либо параметры через RunScriptSync |
CS0246 Тип не найден |
Часто с Resolve<T>() где T из non-trusted asm |
То же что CS0234 |
5. Типичные column-name ловушки (SQL)¶
Всегда проверять через DDL (Glob DB_MSSQL/dbo.{Table}.Table.sql):
| Таблица | Не существует | Реально |
|---|---|---|
SmartScripts |
IsDisabled, Disabled |
Нет такой колонки — SS не disabled'ятся на уровне таблицы |
Comments |
CommentId (camelCase) |
CommentID (CAP) |
Comments |
UserId |
UserID |
Tasks |
Name, Title |
TaskText |
Subcategories |
Name |
Description (иногда) или CategoryName |
SettingsCustom |
SettingKey, SettingValue |
Key и Value |
6. Локальный smoke loop (если делегату дали local core)¶
Когда доступны локальные PG + core (см. env/local-core-from-local-pg.md):
# После записи <script>.cs — compile + execute через ad-hoc endpoint
PAT=$(cat /tmp/1f-local-pat.txt)
curl -sS -X POST "http://localhost:5050/api/admin/smart/scripts/editor/execute" \
-H "1F-Pat: $PAT" -H "Content-Type: application/json" \
-d "$(jq -n --arg code "$(cat <script>.cs)" \
'{script:{id:0,scriptCode:$code,contextType:"None",language:"CSharp"}}')" \
| jq '.data'
Возврат: .data.result — успех (или null + .data.output с Stack trace).
Правила loop'а для делегатов:
1. Если CS-ошибка — прочитать message + line, починить, повторить. Max 3 итерации.
2. Если compile OK но RESULT не совпадает с эталоном — не править код наугад. Записать расхождение как «Known difference» в journal, вернуть.
3. Если 3 итерации compile OK не дали — stop. Вернуть текущий файл + все ошибки в journal. Не ломать голову.
4. НЕ создавать сохранённые SS (id>0). Только ad-hoc id=0.
5. НЕ UPDATE/DELETE в БД. Только SELECT через SQL.query/scalar.
6. НЕ использовать обходы запрещённые в промпте (напр. SQL на SettingsCustom если есть GetSettingValue).
7. Expected contract format (для промптов)¶
В промпте делегату явно задать ожидаемый RESULT. Без этого он может вернуть {ok:true} от catch-handler'а с дефолтами (ложный success).
Пример:
## Expected output
Script should return (structure):
```json
{
"ok": true,
"checks": [...], // ≥ 3 элемента
"alerts": int, // 0..10
"version": "v1-csharp"
}
ok=false — в journal записать как Known difference, не исправлять код наугад.
```
8. Migration journal шаблон (для портов JS→C#)¶
Если порт существующего JS SS на C#, делегат должен вернуть 2 файла:
1. <name>-csharp.cs — сам код
2. migration-journal.md с разделами:
- Summary
- Decisions (почему конкретные choices)
- Type signatures verified (список методов + line-ссылки на CSharpApi source)
- Column names verified (список таблиц + DDL-ссылки)
- Traps & workarounds (что наткнулись, как обошли)
- Known functional differences from JS (критично для shadow-теста)
- Open questions
- Compilation risk (честно — где не уверен)