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

JS/Jint SmartScripts — справочник паттернов и граблей

Практический справочник для написания JS SmartScripts в 1Форме. Каждый паттерн проверен на production (HD). Цель — не повторять ошибки, найденные в SS 492 (Анфиса), SS 493 (AI Search; sync-часть с 2026-04-11 в Module:ai-search-engine SS 592, async — searchDb/cascadeSearch и др. — остаются в SS 493), Telegram-синхронизации и GitLab-интеграции.

Все доступные Smart Actions с сигнатурами — MCP-серверы /mcp-actions-* (21 группа, ~205 действий). Каталог: docs/reference/api/mcp-catalog.md.

API reference: docs/domains/smart-actions/js-scripting-jint.md


0. Версионирование — обязательно

Каждый SmartScript и JS-вставка обязаны содержать комментарий с версией и датой в начале скрипта:

// v1 | 2026-03-08 15:30 | Начальная версия
// v2 | 2026-03-08 16:45 | Добавлена проверка прав
// v3 | 2026-03-10 09:00 | Фикс: TTL кэша 24ч → 1ч
  • Версия инкрементируется при каждом изменении
  • Дата/время — момент правки (local time)
  • Краткое описание — что изменилось
  • Если скрипт без версии — добавить v1 при первом касании

0.1. Cross-platform SQL — MSSQL + PG

DB_TYPE — глобальная переменная, доступная в JS SmartScripts: "MSSQL" или "PG".

Проблема: Key, Value — reserved words. MSSQL требует [Key], PG — "key" (lowercase). TOP N — только MSSQL, PG — LIMIT N.

Паттерн: функция qi() (quote identifier):

var IS_PG = DB_TYPE === "PG";
function qi(name) { return IS_PG ? '"' + name.toLowerCase() + '"' : '[' + name + ']'; }

Использование:

// SettingsCustom — самый частый случай
var val = SQL.scalar(
    "SELECT " + qi('Value') + " FROM SettingsCustom WHERE " + qi('Key') + " = 'my_setting'");

// SELECT с TOP/LIMIT
var rows = SQL.query(
    "SELECT " + (IS_PG ? "" : "TOP 1 ") + qi('Value') +
    " FROM SettingsCustom WHERE " + qi('Key') + " = '" + escSql(key) + "'" +
    (IS_PG ? " LIMIT 1" : ""));

// INSERT/UPDATE
SQL.query("INSERT INTO SettingsCustom (" + qi('Key') + ", " + qi('Value') + ") VALUES (@k, @v)",
    { k: key, v: value });

Полный набор хелперов (для скриптов с тяжёлыми SQL):

var IS_PG = DB_TYPE === "PG";
function qi(name) { return IS_PG ? '"' + name.toLowerCase() + '"' : '[' + name + ']'; }
var NL = IS_PG ? '' : ' WITH(NOLOCK)';
function topN(n) { return IS_PG ? '' : 'TOP ' + n + ' '; }
function limitN(n) { return IS_PG ? ' LIMIT ' + n : ''; }
var NOW_FN = IS_PG ? 'NOW()' : 'GETDATE()';
var RV_COL = IS_PG ? "xmin::text::bigint" : "CAST(row_version AS BIGINT)";
var RV_ORDER = IS_PG ? "xmin" : "row_version";
function slen(col) { return IS_PG ? 'LENGTH(' + col + ')' : 'LEN(' + col + ')'; }
function ifnull(expr, def) { return IS_PG ? 'COALESCE(' + expr + ', ' + def + ')' : 'ISNULL(' + expr + ', ' + def + ')'; }

Gotchas: - PG хранит identifiers в lowercase → qi('Value')"value" (не "Value")

Backend-слой при DataTable -> Dictionary использует StringComparer.OrdinalIgnoreCase, поэтому доступ к колонкам результата через объект/словарь в runtime обычно не зависит от регистра имени поля. Это не отменяет необходимость правильно квотировать identifiers в самом SQL для PG. - MSSQL: sc.key без скобок → Incorrect syntax near the keyword 'key' - PG: [Key] → синтаксическая ошибка (квадратные скобки не поддерживаются) - WITH(NOLOCK) — только MSSQL, на PG не существует. var NL — динамический хинт - LEN()LENGTH() (PG). ISNULL()COALESCE() (PG) - row_version (MSSQL) → xmin (PG) для change tracking - String concat: MSSQL +, PG ||. Пример: IS_PG ? "a || b" : "a + b" - N'...' (unicode prefix) — только MSSQL, на PG убирать

Lua-аналог: get_mspg_query() с тегами {MS}...{/MS} и {PG}...{/PG} + $NOW.


1. CACHE API — больше чем key-value

Базовое использование

CACHE.set("my_key", "value", 3600);   // lifetime в секундах
var val = CACHE.get("my_key");         // → string или null
CACHE.delete("my_key");

TTL gotcha: default = MaxInt. Без явного lifetime значение живёт вечно (до рестарта процесса). Всегда указывай TTL.

// Плохо — кэш никогда не протухнет
CACHE.set("nav_index", data);

// Хорошо — явный TTL
CACHE.set("nav_index", UTILS.json_encode(data), 86400);  // 24ч

Значение — строка. Объекты → UTILS.json_encode() перед set, UTILS.json_decode() после get.

CACHE = общее пространство имён с бэкендом

CACHE в JS/Lua скриптах — тот же ScriptDataCache (LuaCache), что использует серверный код 1Формы. Это discovery: из скрипта можно читать и инвалидировать серверные кэши.

js-scripting-jint.md:189 — «Использует тот же ScriptDataCache (LuaCache), что и Lua-скрипты — кэш общий.»

Следствия: - Ключи из разных скриптов (JS и Lua) конфликтуют. Используй уникальные префиксы: ss492_, ai_search_, gitlab_. - При обновлении скрипта через SQL (не через admin API) CACHE не сбрасывается. Нужно менять имя ключа или ждать TTL. Через admin API — кэш скрипта инвалидируется автоматически (SQLRefactor/CLAUDE.md).

Rate limiting через CACHE

Паттерн из SS 492 (Анфиса): ограничение вызовов на пользователя.

function checkRateLimit(userId, maxPerHour) {
    var key = "ss492_rate_" + userId;
    var raw = CACHE.get(key);
    var count = raw ? parseInt(raw) : 0;
    if (count >= maxPerHour) return false;
    CACHE.set(key, String(count + 1), 3600);  // TTL = 1 час
    return true;
}

if (!checkRateLimit(authorId, 500)) {
    postComment(taskId, "Превышен лимит запросов. Попробуйте позже.");
    return;
}

docs/domains/ai/README.md — rate limit 500 req/hr/user, daily token budget 5M.

State machine через CACHE

Бюджет токенов, история диалогов, промежуточные состояния — всё хранится в CACHE с TTL.

// Трекинг бюджета за день
var budgetKey = "ss492_budget_" + today();
var raw = CACHE.get(budgetKey);
var spent = raw ? parseInt(raw) : 0;
if (spent + estimatedTokens > DAILY_BUDGET) {
    // budget hit — отказ или fallback на дешёвую модель
    return;
}
CACHE.set(budgetKey, String(spent + usedTokens), 86400);

docs/domains/ai/README.md — budget management: daily cap 5M tokens.

CACHE.get — проверка на null и "null"

var raw = CACHE.get("my_key");
// raw === null → ключа нет
// raw === "null" → кто-то закэшировал json_encode(null)!

if (!raw || raw === "null") {
    // rebuild
}

SQLRefactor/CLAUDE.md — «CACHE:get проверять на ~= nil и ~= "null". Нет API для ручного сброса».


2. HTTP — внешние вызовы

Базовый паттерн

var resp = HTTP.send_http_request(
    "POST",
    "https://api.example.com/endpoint",
    null,                          // query params
    {
        "Content-Type": "application/json",
        "Authorization": "Bearer " + token
    },
    UTILS.json_encode(body),       // rawBody
    null                           // options
);

HTTP response — обёртка, не raw JSON

Проблема: HTTP.send_http_request возвращает не десериализованный JSON, а обёрточный объект.

Решение:

var resp = HTTP.send_http_request("POST", url, null, headers, rawBody);

// НЕПРАВИЛЬНО — так не работает
// var data = resp.data[0].embedding;

// ПРАВИЛЬНО
if (resp.InnerError) throw new Error(resp.InnerError);
var content = resp.HttpResponse.ResponseContent;  // строка!
var parsed = UTILS.json_decode(content);
var data = parsed.data[0].embedding;

smartscript-migration-spec.md:434-447 — подтверждённая gotcha.

DefaultHttpClientV2 timeout 15 секунд

Проблема: внутренний HttpClientType.DefaultHttpClientV2 имеет TimeoutSeconds = 15. Для тяжёлых запросов (Claude API, большие payload) 15 секунд мало → TaskCanceledException.

Решение: прямой вызов CLR через UTILS.resolve_instance:

var httpSvc = UTILS.resolve_instance(
    "Valhalla.Services",
    "Valhalla.Services.HttpRequestService"
);

// Создаём options с увеличенным таймаутом
var options = UTILS.create_instance(
    "Valhalla.Services",
    "Valhalla.Services.Models.HttpRequestOptions"
);
options.RequestTimeoutMs = 120000;  // 2 минуты

var result = httpSvc.SendBodyContainingHttpRequest(
    url, "POST", rawBody, headersDict, options
);

docs/domains/ai/README.md — gotcha «DefaultHttpClientV2 timeout 15с».

ToJSON баг — Dictionary не оборачивается в {}

Проблема: при сериализации Dictionary через ToJSON (внутренний сериализатор HTTP API) пары key-value не оборачиваются в {}. JSON невалиден.

Решение: создавать словари через UTILS.create_instance:

var headers = UTILS.create_instance(
    "System.Private.CoreLib",
    "System.Collections.Generic.Dictionary`2[[System.String],[System.String]]"
);
headers.Add("Content-Type", "application/json");
headers.Add("X-Api-Key", apiKey);

docs/domains/ai/README.md — «ToJSON баг → обход через create_instance».

Retry с backoff

Паттерн для внешних API (Claude, OpenAI, Cohere):

function httpWithRetry(method, url, headers, body, maxRetries) {
    maxRetries = maxRetries || 3;
    for (var attempt = 0; attempt < maxRetries; attempt++) {
        var resp = HTTP.send_http_request(method, url, null, headers, body);
        var status = resp.HttpResponse ? resp.HttpResponse.StatusCode : 0;
        if (status === 200) return resp;
        if (status === 429 || status === 500 || status === 529) {
            // backoff: ~1s, ~2s, ~4s
            var waitMs = Math.pow(2, attempt) * 1000;
            var start = Date.now();
            while (Date.now() - start < waitMs) { /* busy wait — Jint не имеет sleep */ }
            continue;
        }
        break;  // 4xx (кроме 429) — не ретраить
    }
    return resp;
}

Важно: в Jint нет setTimeout/sleep. Busy wait допустим при коротких паузах (до 5 секунд), но считается в MaxStatements.

SSL для внутренних стендов

// *.{host} — self-signed сертификаты
// HTTP.send_http_request по умолчанию НЕ проверяет сертификаты
// Дополнительных действий не нужно
var resp = HTTP.send_http_request("GET", "https://{host}/api/...");

3. UTILS.resolve_instance — CLR interop

Зачем

Прямой доступ к .NET-сервисам 1Формы из SmartScript — без SQL, без HTTP hop. Сервис резолвится из DI-контейнера ASP.NET Core.

Когда использовать: когда SQL = обход сервисного слоя. Пример: AI Search (sync — Module:ai-search-engine SS 592 после Etap 5; async — SS 493) — 0 строк SQL для поиска, всё через .NET-сервисы in-process.

resolve_instance — DI resolve

var searchSvc = UTILS.resolve_instance(
    "Valhalla.Services",                         // assembly name
    "Valhalla.Services.SearchService"             // full type name
);
var result = searchSvc.SimpleSearch(query, userId);

smartscript-migration-spec.md:117-119 — паттерн resolve_instance для ISearchService.

create_instance — Activator.CreateInstance

Создание объектов напрямую (когда нет DI-регистрации):

// Словарь (для HTTP headers и т.п.)
var dict = UTILS.create_instance(
    "System.Private.CoreLib",
    "System.Collections.Generic.Dictionary`2[[System.String],[System.String]]"
);

// List<int>
var list = UTILS.create_instance(
    "System.Private.CoreLib",
    "System.Collections.Generic.List`1[[System.Int32]]"
);

// Npgsql connection (для PG fallback)
var conn = UTILS.create_instance(
    "Npgsql",
    "Npgsql.NpgsqlConnection",
    [connStr]    // constructor args — массив
);

CLR static-классы — sanitization через TextUtils

TextUtils — не в DI (все методы static), поэтому resolve_instance не сработает. Но create_instance (Activator) создаёт инстанс, а Jint через ObjectWrapper видит static-методы. Проверено на dev 2026-04-11.

var tu = UTILS.create_instance("Valhalla.Utils", "Valhalla.Utils.Text.TextUtils");

// FTS (CONTAINS/CONTAINSTABLE) — tokenize, escape, FORMSOF, обрезка длины
var ftsQuery = tu.FormSearchParameterForFullTextContainsSearch(userInput, false);
// "тест поиск" → FORMSOF(INFLECTIONAL, "тест") and FORMSOF(INFLECTIONAL, "поиск")
// второй аргумент true = искать в альтернативной раскладке (EN↔RU)

// LIKE — bracket escaping [%], [_], [[]
var likeQuery = tu.FormSearchParametersForStandartSearch(userInput);
// "test%_[special" → "test[%]_[[]special"

// Обрезка по лимиту (Constants.SearchTextLengthLimit)
var trimmed = tu.CutSearchStringIfTooLong(userInput);

Зачем: production-tested sanitization (используется в Grids, Reports, CommentsFeed, TaskTextFilter), не нужно дублировать логику в JS. Не нужна доработка бэка — create_instance уже есть.

Ключевое: create_instance, не resolve_instance. Static-классы не регистрируются в Autofac → resolve_instance бросит исключение.

Gotcha: не все типы резолвятся

Тип create_instance Workaround
List<int>
CancellationToken ✅ value type
GridSettings ✅ (но namespace Dto.AgGrid.GridSettings, НЕ Dto.NewDataSources.GridSettings)
GetDataSourceRequest ❌ возвращает null Duck-typed JS object

smartscript-migration-spec.md:370-378

Duck typing — обход create_instance

Jint маппит JS-объект на C#-параметр по совпадению имён свойств:

// GetDataSourceRequest не резолвится через create_instance
// Но C#-метод примет JS-объект с нужными полями:
var dsRequest = {
    Request: rowRequest,
    GridSettings: gridSettings,
    UserId: 28,
    TaskIdsOnly: true
};
service.GetTasksDataAsync(dsRequest, ct);  // работает!

smartscript-migration-spec.md:399-405

Правильные пути сервисов

Сервис Assembly Type
RequestHandlerService Valhalla.Services Valhalla.Services.RequestHandlerService
ISearchService Valhalla.Services Valhalla.Services.SearchService
CommentService Valhalla.Services Valhalla.Services.CommentService
HttpRequestService Valhalla.Services Valhalla.Services.HttpRequestService

НЕ Valhalla.Services.AgGridServices.RequestHandlerService — неверный путь, не найдёт.

smartscript-migration-spec.md:382-387

Риски CLR interop

  • Привязка к internal API — нет гарантий стабильности между версиями.
  • Namespace и assembly могут меняться при рефакторинге.
  • Если тип не в whitelist CLR-типов SmartScript → ошибка.

4. SMART — действия в контексте задачи

PostComment

SMART.execute_action_in_task_context("PostComment", taskId, {
    Task: taskId,                    // ОБЯЗАТЕЛЬНО — иначе NullRef
    CommentText: "Текст",
    Recipients: [authorId]           // кому адресован ответ
});

Gotcha: PostComment NullRef без параметра Task. SMART.execute_action_in_task_context("PostComment", ...) без Task в params → binder добавляет pars[2]=null(int)null = NullReferenceException.

docs/domains/ai/README.md — gotcha подтверждена.

Смена состояния

SMART.execute_action_in_task_context("ChangeTaskState", taskId, {
    StepId: targetStateId,
    Comment: "[auto:gitlab-sync] Создана ветка feature/123-fix"
});

Обновление ДП

SMART.execute_action_in_task_context("UpdateExtParam", taskId, {
    ExtParamId: 12408,
    Value: branchName
});

Async-выполнение

// async = true — выполнение в фоне (не блокирует скрипт)
SMART.execute_action_in_task_context("PostComment", taskId, {
    Task: taskId,
    CommentText: "Фоновая операция"
}, true);

5. include() / import() — модуляризация

Два механизма загружают library-скрипты (IsLibrary=true). Отличаются временем выполнения и областью видимости.

Механизм Вызов Когда применим Как резолвит
include(X) include(496) / include("agent-tools-local") Синхронный. Можно на top level. ID или Description
import(X) await import("Module:agent-utils") Async. Только внутри async-функции. Description (рекомендуется) — не ломается при dev ↔ HD restore

Глубина include-рекурсии — не более 5 уровней.

js-scripting-jint.md:210-215

Паттерн: модульная архитектура (SS 492 Анфисы)

С Wave 1-5 (2026-04) Анфиса полностью на ES-модулях через import():

// SS 492 — оркестратор (фрагмент)
AgentUtils = await import('Module:agent-utils');
AgentConfig = await import('Module:agent-config');
// ... 40+ импортов
// Остались 2 include (отдельный трек миграции / не в плане):
include("ai-search");
include("agent-tools-1c");

Полный инвентарь активных SS — ss-includes.md.

Рекомендация

Выносить в отдельные library-SS: - Конфигурацию (константы, ID, SettingsCustom-ключи) - Утилиты (парсинг, форматирование, retry-логику) - API-обёртки (Claude, OpenAI, GitLab, Telegram)

Новый код — через import() с префиксом Module: в Description. Один параметризованный скрипт + библиотеки лучше трёх копий одного кода (антипаттерн P5 из Telegram-аудита).

Грабля: var в async IIFE не виден include-скриптам

JsScriptEngine.ExecuteScriptAsync оборачивает код в (async () => { ... })(). Любой var внутри хойстится на scope этой IIFE-функции, не на engine globals. include() выполняется через engine.Execute() — видит только engine globals.

Симптом: include-скрипт получает ReferenceError на переменную, которая «точно объявлена» в orchestrator.

Реальный пример (agent-loop v223):

// Line 264 — implicit global (видно в include):
_currentTaskId = null;

// Line 1263 — var-дубликат, shadow'ил глобал (НЕ видно в include):
var _currentTaskId = 0;  // ❌
Все include-скрипты падали с ReferenceError на _currentTaskId. Fix — убрать var.

Правило: Переменные, которые должны быть видны include-скриптам, объявлять через implicit global (_foo = value), не var _foo = value. Перед добавлением переменной — grep на совпадения.

Связанный случай: внутри самого library-скрипта var _FOO = true тоже не создаёт engine-global — родительский SS не увидит его через implicit global. Пример: SS 612 dispatcher, починено 2026-04-04 (убрали var из _ANFISA_JOB_MODE = true).

Грабля: include() и import() пишут в одни globals — последний побеждает

Оба механизма декларируют функции/переменные в engine globals. Если функция с одним именем определена в обоих — выигрывает тот, что выполнился позже. В оркестраторе типично import()-ы идут после include()-ов → import перезаписывает include.

Реальный пример: toolFindDocsFiles определена в agent-tools-docs.js (старый include SS 498) и ai-search-docs.js (import Module:ai-search-docs). Правка в SS 498 не имела эффекта — runtime использовал версию из Module-скрипта.

Правило при правке функции: 1. grep funcName по всем library-скриптам. 2. Если больше одного определения — править все. Или (надёжнее) убедиться что правишь ту, что в активной import-цепочке. 3. При наличии одновременно include-версии и import-версии — import = источник правды.


6. Безопасность и секреты

Никогда не хардкодить токены в SmartScript

P1 из Telegram-аудита: три бот-токена Telegram вшиты прямо в Lua-код SmartScript. Любой администратор с доступом к SmartScripts видит токены.

docs/domains/integrations/audit-2026-03-07.md:172-174

SettingsCustom — правильное хранение

// Чтение секрета в рантайме
var token = SQL.scalar(
    "SELECT Value FROM SettingsCustom WHERE [Key] =@name",
    { name: "gitlab_private_token" }
);
token = decrypt(token);  // если XOR-encoded

Для XOR-encoding: ключ хранится в конфигурации (appsettings.json), значение — hex-строка в SettingsCustom.

gitlab-1forma-integration-spec.md:415-424 — конфигурация секретов. smartscript-migration-spec.md:204-219 — SettingsCustom для API keys (AI Search).

encrypt/decrypt

var encrypted = encrypt("secret_value");   // → зашифрованная строка
var original = decrypt(encrypted);          // → исходное значение

Встроенные функции используют серверный ключ шифрования. Подходят для хранения в БД.

Publications [AllowAnonymous] — валидация обязательна

Публикации с анонимным доступом открыты всему интернету. Входящие данные нельзя доверять.

// Верификация webhook secret (GitLab)
var secret = decrypt(SQL.scalar(
    "SELECT Value FROM SettingsCustom WHERE [Key] ='gitlab_webhook_secret'"
));
if (params["secret"] !== secret) {
    RESULT = { error: "unauthorized" };
    return;
}

gitlab-1forma-integration-spec.md:196-203


7. Publications как webhook endpoint

Архитектура

Внешний сервис (GitLab, Telegram, ...)
  → POST /app/v1.2/api/publications/action/{alias}
    → PublishedObjectsController [AllowAnonymous]
      → SmartPublicationsService
        → ActionsPack:
          1. HTTP-ответ {"ok": 200}      (мгновенный ответ, Action=88)
          2. JS SmartScript               (основная логика, async)

Payload доступен через EVENTPARAMS["PublishedObjectParameters"].

ВАЖНО: EVENTPARAMS доступен ТОЛЬКО на верхнем уровне скрипта. Внутри try-catch блоков не виден (Jint scope quirk). Сохраняй в переменную ДО try-catch.

../notifications/admin.md — настройка публикаций. docs/domains/integrations/audit-2026-03-07.md:22-29 — пример data flow.

Парсинг входящего payload

GOTCHA (2026-03-08): PublishedObjectParameters — НЕ плоский объект. Структура: - params.requestBody — JSON-тело запроса (объект) - params.headers — HTTP-заголовки (ключи в mixed-case: x-Gitlab-Token, x-Gitlab-Event, content-Type)

var raw = EVENTPARAMS["PublishedObjectParameters"];
var params = typeof raw === "string" ? UTILS.json_decode(raw) : raw;

var headers = params.headers || {};
var body = params.requestBody || {};

// Headers приходят в mixed-case — нужен case-insensitive поиск
function getHeader(name) {
    var lower = name.toLowerCase();
    var keys = Object.keys(headers);
    for (var i = 0; i < keys.length; i++) {
        if (keys[i].toLowerCase() === lower) return headers[keys[i]];
    }
    return "";
}

var event = getHeader("X-Gitlab-Event") || body.object_kind;

PostComment в контексте публикаций

GOTCHA (2026-03-08): CommentAuthor обязателен при вызове из анонимной публикации. Без него SessionUserId=-1 → NullRef → HTTP 500.

var ROBOT_USER_ID = 3;  // Робот 1Ф

SMART.execute_action_in_task_context("PostComment", taskId, {
    CommentAuthor: ROBOT_USER_ID,  // ОБЯЗАТЕЛЬНО в публикациях!
    Task: taskId,
    CommentText: "[auto:gitlab] MR !42 принят",
    SilentComment: false,
    ForcedEmail: false,
    CommentSMS: false,
    NoSubscription: true
});

ChangeExtParamValue — обновление ДП из скрипта

Действие называется ChangeExtParamValue (не UpdateExtParam).

SMART.execute_action_in_task_context("ChangeExtParamValue", taskId, {
    Task: taskId,
    User: ROBOT_USER_ID,
    ExtParam: epId,
    Value: String(value),
    WriteCommentOnChange: false
}, false);

Table ДП — формат Value

ChangeExtParamValue работает для Table ДП. Value — строка с префиксом-операцией.

Добавление строки (+):

// columnId без кавычек, значения в "First"
Value: '+[{111:{"First":"текст"},222:{"First":"42"}}]'

Изменение строки (=):

// "First" = rowId строки, "Second" = объект с колонками
Value: '={"First":"555","Second":{111:{"First":"новый текст"}}}'

Массовое изменение (=[...]):

Value: '=[{"First":"1","Second":{100:{"First":"a"}}},{"First":"2","Second":{100:{"First":"b"}}}]'

Удаление строки (-):

Value: '-555'   // 555 = rowId

Полная перезапись (# smart merge, | wipe+create):

// # — удаляет отсутствующие, добавляет новые, обновляет совпадающие
Value: '#{"1":{11:{"First":"текст1"}},"2":{11:{"First":"текст2"}}}'
// | — удаляет все строки, создаёт заново (если rowId неизвестны)
Value: '|{"1":{11:{"First":"текст1"}},"2":{11:{"First":"текст2"}}}'

Экранирование в Value: "\", \\\, '''. Переносы \n\r — без экранирования.

Колонка SelectUsers:

Value: '+[{9:{"First":"{\\"Users\\":{\\"Added\\":[2122]}}"}}]'

Колонка Выпадающий список: "First" = текст, "Second" = значение (обязателен для выпадающих).

Источник: архивное руководство администратора по smart expressions, секция «Работа с ДП Таблица».

Anti-loop маркеры

При двусторонней синхронизации (1Ф ↔ GitLab, 1Ф ↔ Telegram) скрипт может триггерить сам себя.

Решение: текстовый маркер в комментариях.

var MARKER = "[auto:gitlab]";

// Входящий скрипт — маркирует свои комментарии
SMART.execute_action_in_task_context("PostComment", taskId, {
    CommentAuthor: ROBOT_USER_ID,
    Task: taskId,
    CommentText: MARKER + " MR !42 принят",
    SilentComment: false,
    ForcedEmail: false,
    CommentSMS: false,
    NoSubscription: true
});

// Исходящий скрипт — проверяет маркер и пропускает свои
if (commentText.indexOf("[auto:gitlab]") >= 0) return;

Два маркера: [auto:gitlab-sync] (вход) и [auto:1forma-sync] (выход) — чтобы различать направление.

gitlab-1forma-integration-spec.md:427-434

Идемпотентность — delivery ID tracking

Telegram и GitLab гарантируют at-least-once delivery. Повторный webhook → дубль комментария.

Решение через CACHE:

function isProcessed(deliveryId) {
    if (!deliveryId) return false;
    var key = "gitlab_delivery_" + deliveryId;
    if (CACHE.get(key)) return true;
    CACHE.set(key, "1", 86400);  // 24ч TTL
    return false;
}

var deliveryId = params["X-Gitlab-Delivery"] || "";
if (isProcessed(deliveryId)) {
    RESULT = { status: "duplicate" };
    return;
}

gitlab-1forma-integration-spec.md:437-454 — паттерн идемпотентности. docs/domains/integrations/audit-2026-03-07.md:209 — P9: нет идемпотентности в Telegram.


8. SQL из SmartScript

SQL.query — 0-indexed List, не Dict

var rows = SQL.query("SELECT TaskID, Description FROM Tasks WHERE SubcatID = @sub", { sub: 5641 });

// НЕПРАВИЛЬНО:
// Object.keys(rows) → ["Capacity", "Count"] — НЕ индексы строк

// ПРАВИЛЬНО:
for (var i = 0; i < rows.Count; i++) {
    var row = rows[i];
    var taskId = row.TaskID;       // PascalCase, как в SQL
    var desc = row.Description;
}

smartscript-migration-spec.md:356-363

SQL.query_one — ⚠️ НЕ РАБОТАЕТ в публикациях

GOTCHA (2026-03-08): SQL.query_one() возвращает null даже для существующих строк в контексте публикаций (OnCallPublishedObject, eventId=106). Причина — Jint interop: метод существует, но маршаллинг результата ломается.

Рабочая замена — SQL.query() + rows[0]:

// НЕ работает: var task = SQL.query_one("SELECT ...", { id: taskId });
// Работает:
var rows = SQL.query("SELECT TOP 1 TaskID, SubcatID, IsClosed FROM Tasks WHERE TaskID = " + parseInt(taskId));
var task = rows.Count > 0 ? rows[0] : null;
if (!task) return;
var subcatId = task.SubcatID;

SQL.query() возвращает .NET List — используй .Count и [0], не .length.

SQL.scalar — скалярное значение

Работает корректно, включая публикации.

var count = SQL.scalar("SELECT COUNT(*) FROM Tasks WHERE SubcatID = @sub", { sub: 5641 });
var name = SQL.scalar("SELECT Value FROM SettingsCustom WHERE [Key] = @n", { n: "my_key" });

GOTCHA: колонка в SettingsCustom — [Key], не Name. Квадратные скобки обязательны (зарезервированное слово).

VECTORDB API (PG) — 1-indexed Dict — [deprecated 2026-05-08]

[deprecated 2026-05-08] Csharp 1f-agent больше не использует VECTORDB API; раздел оставлен для legacy JS SmartScript'ов. VECTORDB всё ещё регистрируется backend'ом при наличии VectorDbConnectionString, но удаление API — отдельная инициатива в core/.

VECTORDB.query() возвращает Dict<int, Dict<string, object>> с ключами от 1, не от 0.

var rows = VECTORDB.query("SELECT task_id, score FROM vector_search.chunks LIMIT 10");
var rowNum = 1;
while (rows[rowNum]) {
    var row = rows[rowNum];
    var taskId = row.task_id;    // lowercase — PG convention
    rowNum++;
}

VECTORDB регистрируется условно — только когда VectorDbConnectionString есть в appsettings.json. Проверка:

var hasVectordb = typeof VECTORDB !== "undefined";

smartscript-migration-spec.md:449-464

Npgsql параметры — @pN, не $N

GOTCHA (2026-03-10, SS 492/498). AddWithValue("$1", value) в Npgsql НЕ биндит позиционные $N. Ошибка: 42P02 — parameter $1 does not exist или silent mismatch.

Причина: AddWithValue ожидает имя параметра, Npgsql автоматически добавляет @ при связывании. SQL должен использовать @pN, AddWithValue — "pN" (без @).

// НЕПРАВИЛЬНО — $N через AddWithValue
pgExec(
  "INSERT INTO table (col1, col2) VALUES ($1, $2)",
  { "$1": val1, "$2": val2 }
);
// → 42P02: parameter $1 does not exist

// ПРАВИЛЬНО — @pN в SQL, pN в AddWithValue
pgExec(
  "INSERT INTO table (col1, col2) VALUES (@p1, @p2)",
  { "p1": val1, "p2": val2 }
);

Cast-синтаксис: @p1::text[], @p1::halfvec(1024) — работает корректно с @pN.

Null-значения: AddWithValue("pN", null) в Npgsql интерпретируется как «нет параметра», не как DBNull. CLR interop из SmartScript не поддерживает System.DBNull.Value. Вместо null передавать safe defaults: "{}" (пустой PG array), 0, "".

Применимо к: pgQueryNpgsql() и pgExec() в agent-tools-docs.js (SS 498).

docs/domains/ai/anfisa-qa-memory-spec.md — секция 10.2, полный разбор.

Ловушки имён колонок — ВСЕГДА проверяй DDL

Имена колонок в 1Форме контринтуитивны. Не угадывай — проверь DDL (DB_MSSQL/dbo.{Table}.Table.sql).

Типичные ошибки:

Таблица Ожидаешь На самом деле Примечание
Subcategories Name Description (varchar 140) Нет колонки Name вообще
ExtParams Name ExtParamName (varchar 900) Нет колонки Name
SettingsCustom Key, Value [Key], [Value] Зарезервированные слова — квотировать через qi()
SmartExpressions Public [Public] (bit) Зарезервированное слово — qi("Public")
Categories Это Разделы, не Категории Категории = Subcategories

Зарезервированные слова (требуют qi() или квадратных скобок): Name, Order, Key, Value, Public, Type, Description, Default. Если колонка — зарезервированное слово, используй qi("ColumnName").

Правило при написании SS с SQL: перед деплоем — для каждого SQL-запроса проверить все колонки по DDL в DB_MSSQL/. Не полагаться на «очевидные» имена.

Правило при создании нового SS через API: - language — enum-строка: "Lua", "JavaScript", "Python" (не "JS", не "Js") - contextType — строка "None" (не null, не 0) - isLibrary — boolean (true/false)


9. Sandbox и ограничения

Параметр Значение
Timeout 5 минут
MaxStatements 1 000 000
CLR-типы Заблокированы по умолчанию — только whitelist
CLR exceptions Перехватываются как JS Error (ExceptionHandler = _ => true)

js-scripting-jint.md:63-82

print() бесполезен для дебага

print() пишет в консоль SmartScript (output движка), но не в комментарии, не в логи. В production — не видно никому.

docs/domains/ai/README.md — gotcha «print() в SmartScript — бесполезно».

Дебаг: _perfLog + служебный тред

Паттерн из SS 492:

var _perfLog = [];

function logPerf(label, startMs) {
    _perfLog.push(label + ": " + (Date.now() - startMs) + "ms");
}

// В конце скрипта — постим лог в служебный тред
if (_perfLog.length > 0) {
    SMART.execute_action_in_task_context("PostComment", taskId, {
        Task: taskId,
        CommentText: "[perf] " + _perfLog.join(", "),
        ThreadCommentId: serviceThreadId,
        Recipients: [CONFIG.ANFISA_USER_ID]
    });
}

docs/domains/ai/README.md — «_perfLog массив + постинг в служебный тред».

logToAutomation — структурированные логи

Для постоянного логирования — AutomationScriptsLog (таблица БД):

// SS 492 v4: замена print → logToAutomation
// Доступен через SMART или SQL в зависимости от версии
SQL.query(
    "INSERT INTO AutomationScriptsLog (ScriptId, Message, TaskId, CreatedAt) VALUES (@sid, @msg, @tid, GETDATE())",
    { sid: 492, msg: "Tool: qmd_search, 230ms", tid: taskId }
);

SESSION_USER — всегда доступен

SESSION_USER.Id (не .UserId). Работает даже при тестовом запуске (id=0, contextType=5).

CONTEXT и EVENTPARAMS при тестовом запуске = undefined.

smartscript-migration-spec.md:367, 477

Async → sync безопасен

task.Result для CLR Task-ов безопасен в ASP.NET Core (нет SynchronizationContext). Deadlock невозможен.

ВАЖНО: await НЕ работает в рекуренсах (синхронный запуск). editor/execute использует ExecuteAsync и поддерживает await, но рекуренс запускает скрипт синхронно — top-level await вызовет ошибку парсинга: "await is only valid in async functions". Всегда использовать .Result.

smartscript-migration-spec.md:430-431

SmartScript Editor API — формат запроса

{
    "script": {
        "id": 0,
        "scriptCode": "...",
        "language": 1
    }
}

НЕ {"id":0, "languageId":1, "code":"..."} — вызовет NullReferenceException (editor.Script = null).

Поля SmartScriptDto: language (ScriptLanguage enum: 0=Lua, 1=JavaScript, 2=Python), eventId (106 для публикаций), isLibrary, contextType, description. Поле languageId не существует — будет проигнорировано, язык останется 0 (Lua). При обновлении без eventId — значение обнулится → EVENTPARAMS undefined.

Save: {"script": scriptDto, "context": {"subcatId": ..., "eventId": ..., "contextType": ...}}.

Multipart upload из SmartScript — CLR обход

HTTP.send_http_request не поддерживает Content-Type: multipart/form-data; boundary=...StringContent в .NET 8+ отклоняет boundary в MediaTypeHeaderValue. HTTP.post_multipart с Value (без FileId) создаёт StringContent без filename в Content-Disposition.

Решение: создать MultipartFormDataContent через CLR:

var multipart = UTILS.create_instance(
  "System.Net.Http", "System.Net.Http.MultipartFormDataContent", []);
var modelContent = UTILS.create_instance(
  "System.Net.Http", "System.Net.Http.StringContent", [modelJson]);
multipart.Add(modelContent, "modelData");
var fileContent = UTILS.create_instance(
  "System.Net.Http", "System.Net.Http.StringContent", [fileText]);
multipart.Add(fileContent, "file", fileName);  // 3-й аргумент = filename

var httpClient = UTILS.create_instance(
  "System.Net.Http", "System.Net.Http.HttpClient", []);
httpClient.DefaultRequestHeaders.TryAddWithoutValidation("1F-Pat", pat);
var response = httpClient.PostAsync(url, multipart).Result;
var body = response.Content.ReadAsStringAsync().Result;
multipart.Dispose();
httpClient.Dispose();

docs/domains/integrations/docs-disk-sync-gitlab.js — рабочий пример (SS 510).

smartscript-migration-spec.md:407-426


10. Table EP — запись строк из SmartScript

Табличные ДП (Table ExtParam) — запись/обновление/удаление строк из JS SS.

API endpoints

Версия Endpoint Описание
v1.0 POST /app/v1.0/api/mobile/tasks/{taskId}/extparamtable Legacy, body: {id: epId, rows: string[]}
v1.2 POST /app/v1.2/api/task/{taskId}/ep/table/{epId}/update Актуальный, DTO ниже

DTO: EpTableRowModifyDto

// Добавить строку в Table EP
var body = {
  rows: [{
    id: 0,                    // 0 = новая строка
    modifyType: "create",     // "create" | "update" | "delete"
    cols: [
      { id: 35911, value: "Валидация" },       // Фаза (Text column)
      { id: 35921, value: "OK" },               // Статус (Text column)
      { id: 35931, value: "YAML parsed" },      // Детали (Text column)
      { id: 35941, value: "2026-03-13T12:00:00" } // Timestamp (DateTime column)
    ]
  }]
};

// Вызов из SS через HTTP.fetch (v1.2 endpoint)
var resp = HTTP.fetch(
  BASE_URL + "/app/v1.2/api/task/" + taskId + "/ep/table/" + epId + "/update",
  { method: "POST", body: JSON.stringify(body), headers: {"Content-Type": "application/json"} }
);

Формат значений в cols

Тип колонки Формат value
Text строка: "some text"
DateTime ISO: "2026-03-13T12:00:00"
Number строка числа: "42.5"
Select/Combobox ID значения: "12755"
Lookup taskId: "98765"

Чтение данных Table EP

POST /app/v1.2/api/task/{taskId}/ep/table/{epId}
Body: { take: 100, skip: 0 }

Ответ: data.data[] — массив строк. Значения в полях c{columnId}, например: - c35911.stringValue — текст - c35941.dateTimeValue — дата

Обновление/удаление

// Обновить существующую строку
{ id: 12345, modifyType: "update", cols: [{ id: 35921, value: "Failed" }] }

// Удалить строку
{ id: 12345, modifyType: "delete", cols: [] }

Из SmartScript: альтернатива через MCP action

MCP-тул mobile_tasks_ext_params_modify (сервер /mcp-ext-params-table-mobile) — обёртка над v1.0 endpoint. Подходит если SS вызывает через SMART actions, а не HTTP.

Граблеe

  • id=0 для новой строки — обязательно, null или отсутствие → ошибка
  • modifyType — строка, не enum-число. "create", не 0
  • При "delete" массив cols можно передать пустым, но поле должно присутствовать
  • v1.0 endpoint принимает rows как string[] (JSON-строки), v1.2 — как объекты

11. Антипаттерны — чеклист «не делай так»

Хардкод ID категорий/пользователей (P6)

Проблема: if (chatId == -123456789) { ... } else if (chatId == -987654321) { ... } — каждый новый клиент/чат требует правки кода.

Решение: конфигурационная таблица или SettingsCustom с JSON.

// Плохо
if (subcatId == 5641) { ... }

// Хорошо
var config = UTILS.json_decode(
    SQL.scalar("SELECT Value FROM SettingsCustom WHERE [Key] ='gitlab_state_mapping'")
);
var mapping = config[String(subcatId)];

docs/domains/integrations/audit-2026-03-07.md:194-196 — P6.

Дублирование кода между скриптами (P5)

Проблема: три копии одного скрипта (SS 7/274/133 в Telegram). Рассинхронизация гарантирована.

Решение: один параметризованный скрипт + include() библиотек.

docs/domains/integrations/audit-2026-03-07.md:190-192 — P5.

SQL вместо сервисного слоя

Проблема: прямой UPDATE Comments SET Content = '...' — обходит валидацию, ACL, логирование.

Решение: SMART.execute_action или UTILS.resolve_instance для вызова сервисов.

docs/domains/integrations/audit-2026-03-07.md:180-183 — P3.

Отсутствие rate limit на внешние API

Проблема: без ограничений скрипт может забить внешний API (Anthropic: 2K RPM, OpenAI: лимиты на org). Блокировка ключа необратима.

Решение: rate limit через CACHE (см. секцию 1).

Игнорирование идемпотентности webhook-ов

Проблема: повторный webhook → дубль комментария, дубль задачи, дубль перехода.

Решение: delivery ID tracking через CACHE (см. секцию 7).

docs/domains/integrations/audit-2026-03-07.md:209 — P9.

Проблема: print() пишет в никуда. 30+ вызовов var_dump в SS 7 (Telegram) засоряют output.

Решение: _perfLog + служебный тред, или logToAutomation.

docs/domains/integrations/audit-2026-03-07.md:216-218 — P11.

Предсказуемые пароли при автосоздании пользователей (P2)

Проблема: {ФамилияИмя}Temp123456 — зная алгоритм, можно войти под любым автосозданным юзером.

Решение: случайный пароль или invite-flow без пароля.

docs/domains/integrations/audit-2026-03-07.md:177-179 — P2.

DELETE в SELECT-запросе (P4)

Проблема: побочный эффект при каждом incoming webhook. Мутация в read-path.

Решение: отдельный scheduled cleanup.

docs/domains/integrations/audit-2026-03-07.md:184-186 — P4.


Связанные документы

Документ Путь
Jint API reference docs/domains/smart-actions/js-scripting-jint.md
Анфиса (SS 492) — архитектура + gotchas docs/domains/ai/README.md
AI Search миграция — Jint Runtime Gotchas docs/domains/search/smartscript-migration-spec.md
Telegram-синхронизация аудит docs/domains/integrations/audit-2026-03-07.md
GitLab-интеграция спека docs/domains/integrations/gitlab-1forma-integration-spec.md
Публикации (admin manual) ../notifications/admin.md
Python scripting docs/domains/smart-actions/python-scripting.md
NLua проблемы на .NET 9 docs/domains/smart-actions/faq-lua-pcall-error-handling.md
Перегрузки CLR-методов в Jint (overload resolution) docs/domains/smart-actions/faq-jint-method-overload-resolution.md
Обзор смарт-действий docs/domains/smart-actions/backend.md