Перейти к содержанию

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/actionInvalidate ключа), поэтому 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.csTrustedAssemblies, 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 (честно — где не уверен)

Пример: anfisa-heartbeat-csharp migration-journal.md.