Organize LLM assets and function lifecycle fixes

This commit is contained in:
Naeel
2026-04-28 13:04:08 +03:00
parent 82eba079f6
commit a534fddd2c
22 changed files with 1409 additions and 72 deletions
+2
View File
@@ -3,11 +3,13 @@
## ⛔ ОТВЕЧАТЬ КРАТКО — АБСОЛЮТНОЕ ПРАВИЛО
- Вопрос → короткий ответ → СТОП. Не рассуждать, не исследовать без команды.
- НЕ делать ничего "попутно" без явной просьбы. Сделал — стоп — ждёшь команды.
- Без приветствий и лишних рассуждений. Код — только по запросу. Не бормотать.
> Подробные правила: [`.github/pravila.md`](pravila.md)
1. Не трогать рабочий код без явного указания.
2. Файлы редактировать локально — `~/remote_dev/` = `~/terra/` на ВМ (sshfs), SCP не нужен.
Перед любым запуском команд проверять, что локальные файлы и содержимое на ВМ синхронизированы; при сомнении ориентироваться на состояние файлов на ВМ.
3. Все команды — **только через SSH**, никогда локально:
```bash
ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10 naeel@5.172.178.213 'КОМАНДА'
+2
View File
@@ -5,6 +5,8 @@
`~/remote_dev/` (локально) примонтирован через sshfs к `~/terra/` на ВМ — **одна ФС**.
Файлы, сохранённые локально, мгновенно видны на ВМ. SCP не нужен.
Перед любым запуском команд сначала убедиться, что локальная копия и содержимое на ВМ синхронизированы; если есть сомнение в рассинхроне, сверяться с файлами именно на ВМ.
Монтирование может слетать. Признак: файлы рассинхронизированы.
```bash
+237
View File
@@ -0,0 +1,237 @@
# LLM Prompt Training Log
Дата: 2026-04-28
Цель: довести универсальный промпт для генерации кода функций до состояния, при котором сгенерированный код проходит синтаксическую проверку и проверку смысла "LLM что делает" на всех языках, с учетом кривых пользовательских условий.
## Правило цикла
1. Составить человеческую задачу для LLM.
2. Получить сгенерированный код.
3. Прогнать код через линтер / syntax check.
4. Прогнать проверку "LLM что делает".
5. Зафиксировать результат в этом файле.
6. Исправить промпт.
7. Повторить на новых задачах и новых языках.
## Цели промпта
- Пользователь может давать неточные, неполные или кривые условия.
- LLM должна уточнять или нормализовать задачу внутри ответа.
- LLM должна возвращать код функции, который соответствует реальному runtime-контуру.
- Сгенерированный код должен быть проверяемым линтером.
- Сгенерированный код должен проходить семантическую проверку "LLM что делает".
- Промпт должен работать на всех поддерживаемых языках.
## Критерии успеха
- Код валиден по синтаксису.
- Код соответствует ожидаемому entrypoint/handler контракту.
- Код не содержит лишнего wrapper-кода, если он не нужен для пользовательского артефакта.
- Ответ LLM объясняет, что делает код.
- Ответ LLM не выдумывает поведение, которого нет в коде.
- На негативных примерах LLM не маскирует ошибки.
## Языки для прогона
- Node.js
- Python
- PHP
- Ruby
- Go
## Формат записи по каждому циклу
- Дата и номер итерации.
- Исходный промпт.
- Набор задач.
- Что сгенерировала LLM.
- Результат линтера.
- Результат проверки "LLM что делает".
- Что сломалось.
- Что изменено в промпте.
- Результат следующего прогона.
## Текущий статус
- Базовая структура журнала создана.
- Следующий шаг: сформировать первый универсальный промпт и прогнать по языкам на множестве задач.
## Итерация 1
### Промпт
- Набор прямых задач на генерацию кода по языкам.
- Требование: код + краткое объяснение.
### Прогон
- Node.js, Python, PHP, Ruby, Go.
### Результат
- Все сгенерированные куски кода прошли syntax/lint check.
- Объяснение присутствовало во всех ответах, но формат заголовка плавал: `**Explanation**`, `**Что делает код**`, `**LLM**`, `**LLM:**`.
- Node.js ответ был runtime-safe в синтаксическом смысле, но prompt ещё не фиксировал достаточно жёстко формат блока объяснения.
- PHP/Ruby ответы местами использовали более свободные runtime-объяснения, чем хотелось бы для единого шаблона.
### Вывод
- Нужен более жёсткий универсальный промпт: точный заголовок блока объяснения, явный запрет на выдуманные SDK/контракты, более тесная привязка к runtime.
### Что изменено в промпте после итерации 1
- Зафиксирован точный заголовок `LLM что делает`.
- Добавлены явные ограничения по runtime-контракту.
## Итерация 8
### Промпт
- Строгий формат ответа сохранился.
- Добавлены более разнообразные задачи: greeting и sum numbers.
### Прогон
- Node.js, Python, PHP, Ruby.
### Результат
- Все четыре языка стабильно выдавали синтаксически корректный код и проходили lint.
- Node.js держит exact heading `LLM что делает` и на greeting, и на sum numbers.
- Python и Ruby также стабильно соблюдают exact heading и fenced code block.
- PHP на части mixed-case ответов всё ещё иногда отдаёт нестандартный заголовок вроде `LLM greeting handler` или `LLM what does`, хотя код и lint проходят.
### Вывод
- Prompt уже достаточно силён для кода и линтера на всех поддерживаемых языках.
- Главная остаточная проблема — полная фиксация заголовка explanation в PHP во всех разновидностях задач.
- Для следующей правки стоит ещё сильнее зажать именно PHP‑формат ответа либо принять это как локальную особенность модели.
- Добавлены запреты на выдуманные SDK, фреймворки и wrapper-код.
## Итерация 2
### Промпт
- Универсальный prompt с акцентом на broken user input, runtime contract и точный раздел `LLM что делает`.
- Go исключён из цикла по явному указанию пользователя.
### Прогон
- Node.js, Python, PHP, Ruby.
### Результат
- На первом проходе Node.js с более тяжёлой формулировкой давал `HTTP 400 invalid JSON: unexpected EOF` от AI endpoint.
- После упрощения Node.js запроса он стал проходить syntax/lint check.
- Python, PHP, Ruby прошли syntax/lint check.
- Во всех ответах explanation был, но заголовок снова не совпал с точным `LLM что делает`; LLM использовала варианты `**LLM:**`, `**Explanation**` и похожие.
### Вывод
- Главная оставшаяся проблема — формат блока объяснения.
- Для следующей итерации нужен ещё более жёсткий формат ответа: literal heading без markdown-обёрток и без альтернативных названий.
### Что изменено в промпте после итерации 2
- Усилен запрет на альтернативные заголовки блока объяснения.
- Добавлено требование, чтобы первая строка раздела была ровно `LLM что делает`.
- Уточнено, что после заголовка должны идти только короткие пункты.
## Итерация 3
### Промпт
- Универсальный prompt с фиксированным разделом `LLM что делает`.
- Кривые и разнотипные пользовательские задачи: приветствие, сумма чисел, default fallback, missing fields.
- Go уже исключён из цикла.
### Прогон
- Node.js, Python, PHP, Ruby.
### Результат
- Python, PHP и Ruby стабильно вернули синтаксически корректный код.
- Node.js дважды отвалился на уровне AI endpoint с `HTTP 400 invalid JSON: unexpected EOF` до получения ответа.
- Для Python/PHP/Ruby explanation присутствовал, но часть ответов оборачивала пояснение в markdown или использовала нестандартную форму заголовка, из-за чего точный парсинг блока `LLM что делает` был нестабилен.
- Линтер везде, где код был получен, показал `✅ Синтаксис корректен.`
### Вывод
- Prompt уже близок к рабочему, но ещё не дожат по формату блока объяснения.
- Нужна ещё более жёсткая инструкция: explanation без markdown-обёрток, ровно один заголовок, затем только пункты.
- Node.js-case надо формулировать проще, иначе AI endpoint иногда вообще не возвращает JSON-ответ.
### Что изменено в промпте после итерации 3
- Уточнено, что раздел `LLM что делает` должен идти без `**`, без code fences и без альтернативных названий.
- Проговаривание структуры ответа стало ещё более жёстким.
## Итерация 4
### Промпт
- Ещё более жёсткая формулировка ответа.
- Короткие и разнообразные задачи: greet, sum numbers, fallback defaults, broken input.
- Go не используется.
### Прогон
- Node.js, Python, PHP, Ruby.
### Результат
- Node.js, Python, PHP и Ruby синтаксически сгенерировались, но формат ответа был нестабилен.
- Node.js вернул ответ с заголовками `Normalized task:` / `Code:` / `LLM что делает:` вместо требуемой точной структуры.
- Python ответил без точного заголовка `LLM что делает` и с markdown-упаковкой блока.
- PHP и Ruby в части случаев вообще не отдали код в fenced-блоке, из-за чего extractor не смог вытащить код.
- Где код удалось извлечь, syntax/lint check проходил.
### Вывод
- Prompt уже умеет удерживать синтаксис, но всё ещё не закрепил формат ответа.
- Следующая правка должна требовать строгую структуру ответа: фиксированный порядок секций, fenced code block и plain-text heading `LLM что делает`.
### Что изменено в промпте после итерации 4
- Усилен запрет на альтернативные заголовки и markdown-упаковку explanation.
- Следующий вариант должен требовать точную структуру ответа с fenced code block.
## Итерация 5
### Промпт
- Строгий шаблон ответа: нормализация, fenced code block, `LLM что делает`.
- Набор разнообразных задач: приветствие, default fallback, суммы чисел.
- Go по-прежнему исключён.
### Прогон
- Node.js, Python, PHP, Ruby.
### Результат
- Python, PHP и Ruby для части задач вернули синтаксически корректный код и соблюли fenced block + heading.
- Ruby с задачей про сумму чисел выполнил требование почти полностью: точный heading `LLM что делает` присутствовал.
- Python/Php с простыми задачами тоже начали соблюдать заголовок и fenced code block.
- Node.js и часть PHP/Python задач всё ещё иногда падали на уровне AI endpoint с `HTTP 400 invalid JSON: unexpected EOF`.
- Ruby с задачей про greeting показал английский заголовок `LLM what it does`, то есть prompt ещё не полностью зафиксировал язык заголовка.
### Вывод
- Структура ответа уже заметно улучшилась на части кейсов.
- Нужно отдельно добить два оставшихся класса проблем:
- endpoint иногда не отдает ответ на Node/часть других задач
- LLM иногда переводит заголовок explanation на английский
### Что изменено в промпте после итерации 5
- Усилен запрет на любые альтернативные заголовки, включая английский перевод.
- Подчёркнуто, что допустим только literal `LLM что делает`.
## Итерация 6
### Промпт
- Очень короткие задачи с жёстким форматом ответа.
- Node.js, Python, PHP, Ruby.
### Прогон
- Node.js, Python, PHP, Ruby.
### Результат
- Python, PHP и Ruby уже стабильно выдали fenced code block, точный `LLM что делает` и синтаксически корректный код.
- Node.js тоже перестал падать на уровне AI endpoint и начал возвращать код.
- Node.js всё ещё печатает заголовок как `LLM Что делает` с заглавной буквой `Что`, то есть строгая фиксация регистра ещё не дожата.
- Где код был получен, lint проходил успешно.
### Вывод
- Prompt почти готов: структура и код в основном держатся, кроме единичного расхождения в регистре заголовка на Node.js.
- Следующий шаг — явно зафиксировать lower-case `что` и ещё раз прогнать короткий цикл.
### Что изменено в промпте после итерации 6
- Добавлено жёсткое указание на точный регистр заголовка `LLM что делает`.
- Запрещены любые заголовки с иным регистром или переводом.
## Итерация 7
### Промпт
- Короткие задачи с минимальной формулировкой.
- Жёстко фиксированный `LLM что делает`.
### Прогон
- Node.js, Python, PHP, Ruby.
### Результат
- Все четыре языка вернули code block и точный заголовок `LLM что делает`.
- Линтер везде показал `✅ Синтаксис корректен.`
- Node.js перестал падать на уровне AI endpoint даже при коротком prompt.
- Формат ответа стал предсказуемым: normalization + fenced code block + explanation.
### Вывод
- Текущий prompt уже работает на коротких и простых кейсах.
- Следующий шаг — проверить его на более разнообразных задачах, чтобы убедиться, что шаблон не ломается за пределами приветствий.
@@ -0,0 +1,72 @@
# Универсальный промпт для генерации кода функций
Дата: 2026-04-28
Статус: черновик для цикла обучения
## Промпт
Ты генерируешь код одной функции для Fission.
Требования:
- Сначала коротко нормализуй задачу, если ввод пользователя кривой, неполный или противоречивый.
- Если не хватает критичного условия, задай один короткий уточняющий вопрос. Если можно безопасно вывести разумное поведение, сделай это и явно опиши допущение.
- Не выдумывай лишнюю инфраструктуру.
- Генерируй только код функции и минимально необходимый контекст для запуска.
- Код должен быть синтаксически корректным для выбранного языка.
- Код должен соответствовать контракту runtime этого языка.
- Не добавляй wrapper-код, который ломает открытие/редактирование исходника.
- Не добавляй лишний вывод, комментарии или объяснения внутри кода.
- Ответ должен иметь строго 3 секции в таком порядке:
1. Нормализованная задача
2. Код в fenced block
3. `LLM что делает`
- В секции кода используй ровно один fenced block с языковым идентификатором.
- В секции `LLM что делает` используй ровно literal string `LLM что делает` с тем же регистром и пробелами; не используй `**`, `Explanation`, английский перевод этого заголовка, другой регистр, другие заголовки или fenced block.
- Сразу после заголовка дай 2-5 коротких пунктов обычным текстом.
- Если код не соответствует задаче, не маскируй это: скажи, что именно изменил и почему.
Формат ответа:
1. Нормализованная задача.
2. Один fenced code block.
3. Ровно заголовок `LLM что делает`.
4. 2-5 коротких пунктов с объяснением.
5. Если есть допущения, перечисли их отдельно.
## Языковые правила
### Node.js
- Используй обычный CommonJS-код функции.
- Экспорт должен быть совместим с runtime Fission.
- Не вставляй ESM export-обвязку в пользовательский код.
- Функция должна возвращать объект ответа или совместимый результат.
- Не придумывай нестандартные поля или внешние SDK, если они не были явно указаны.
### Python
- Используй обычную функцию entrypoint.
- Не добавляй лишние импорты.
- Не усложняй код без необходимости.
- Не придумывай чужие фреймворки или классы, если задача просит простой handler.
### Go
- Пиши только синтаксически корректный код.
- Следи за package, imports и сигнатурами.
- Не добавляй мусорный boilerplate.
- Не ломай контракт функции выдуманными обертками или неподходящими типами.
### PHP
- Возвращай корректный PHP-код функции.
- Следи за открывающим тегом и синтаксисом массива/строк.
- Не выдумывай несуществующие `Fission\\Contract` классы или SDK, если задача про обычную функцию.
### Ruby
- Возвращай корректный Ruby-код функции.
- Следи за `def`/`end` и структурой хеша.
- Не добавляй лишние внешние зависимости, если они не нужны задаче.
## Правило проверки
Любой сгенерированный ответ после этого промпта проходит два шага:
- syntax/lint check
- проверка "LLM что делает"
Если хотя бы один шаг не проходит, промпт считается недоработанным и должен быть исправлен.
+24
View File
@@ -0,0 +1,24 @@
# LLM Workspace
Эта папка содержит все материалы, связанные с циклом обучения и проверки промптов для генерации кода.
## Содержимое
- `LLM_PROMPT_TRAINING_2026-04-28.md` — журнал итераций, выводы и изменения промпта.
- `LLM_PROMPT_UNIVERSAL_DRAFT_2026-04-28.md` — текущий универсальный черновик промпта.
- `llm_prompt_round1.py` .. `llm_prompt_round7.py` — harness-скрипты для прогонов LLM по разным наборам задач.
- `test_ai_ask.py` — базовый набор запросов к LLM ask endpoint.
- `test_llm_syntax.sh` — syntax/lint suite для проверки сгенерированного кода.
## Правило работы
- Сначала фиксировать промпт и замысел в документации.
- Потом прогонять много разнотипных задач.
- Обязательно проверять сгенерированный код линтером.
- Отдельно проверять смысл ответа через блок `LLM что делает`.
- После каждого цикла обновлять промпт и журнал.
## Что не хранится здесь
- Секреты и токены.
- Не относящиеся к LLM материалы.
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
import json
import re
import urllib.request
ASK_API = "https://fission.kube5s.ru/console/api/ai/ask"
CHECK_API = "https://fission.kube5s.ru/console/api/ai/check"
HEADERS = {"X-Test-Sub": "livetest@test.local", "Content-Type": "application/json"}
CASES = [
{
"lang": "nodejs",
"question": "Сгенерируй только код Fission-функции на Node.js. Задача: принять JSON с полем name и вернуть ответ 200 с приветствием. Код должен быть синтаксически корректным. После кода отдельным блоком кратко объясни, что делает код.",
},
{
"lang": "python",
"question": "Сгенерируй только код Fission-функции на Python. Задача: принять словарь context, взять context['name'] если он есть и вернуть строку приветствия. Код должен быть синтаксически корректным. После кода отдельным блоком кратко объясни, что делает код.",
},
{
"lang": "php",
"question": "Сгенерируй только код Fission-функции на PHP. Задача: вернуть JSON-ответ 200 с приветствием, если имя есть в context. Код должен быть синтаксически корректным. После кода отдельным блоком кратко объясни, что делает код.",
},
{
"lang": "ruby",
"question": "Сгенерируй только код Fission-функции на Ruby. Задача: принять context и вернуть greeting JSON с именем из context, если оно есть. Код должен быть синтаксически корректным. После кода отдельным блоком кратко объясни, что делает код.",
},
{
"lang": "go",
"question": "Сгенерируй только код Fission-функции на Go. Задача: принять запрос и вернуть 200 с приветствием, если имя передано, иначе использовать значение по умолчанию. Код должен быть синтаксически корректным. После кода отдельным блоком кратко объясни, что делает код.",
},
]
def post(url: str, payload: dict) -> dict:
body = json.dumps(payload).encode()
req = urllib.request.Request(url, data=body, headers=HEADERS)
with urllib.request.urlopen(req, timeout=90) as resp:
return json.loads(resp.read())
def extract_code(answer: str) -> str:
match = re.search(r"```(?:[a-zA-Z0-9_+-]+)?\n(.*?)\n```", answer, re.S)
return match.group(1).strip() if match else ""
def explain_block(answer: str) -> str:
parts = re.split(r"```\s*$", answer, maxsplit=1)
if len(parts) > 1:
return parts[1].strip()
return ""
for case in CASES:
ask_resp = post(ASK_API, {"question": case["question"]})
answer = ask_resp.get("answer", ask_resp.get("error", "???"))
code = extract_code(answer)
explanation = explain_block(answer)
check_resp = post(CHECK_API, {"language": case["lang"], "code": code}) if code else {"ok": False, "result": "no code extracted"}
print(f"=== {case['lang']} ===")
print("PROMPT:")
print(case["question"])
print("\nANSWER:")
print(answer)
print("\nEXTRACTED CODE:")
print(code)
print("\nEXPLANATION FOUND:", bool(explanation))
if explanation:
print(explanation)
print("\nLINT:")
print(json.dumps(check_resp, ensure_ascii=False, indent=2)[:2000])
print("\n" + "=" * 72 + "\n")
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env python3
import json
import re
import urllib.request
import urllib.error
ASK_API = "https://fission.kube5s.ru/console/api/ai/ask"
CHECK_API = "https://fission.kube5s.ru/console/api/ai/check"
HEADERS = {"X-Test-Sub": "livetest@test.local", "Content-Type": "application/json"}
PROMPT = """Ты генерируешь код одной функции для Fission.
Требования:
- Если ввод кривой, коротко нормализуй задачу.
- Если не хватает важного условия, задай один короткий вопрос или явно опиши допущение.
- Верни только код функции и отдельный блок LLM что делает.
- Код должен быть синтаксически корректным и соответствовать runtime выбранного языка.
- Не добавляй лишний wrapper-код, лишние зависимости или выдуманные SDK.
Языковые правила:
- Node.js: CommonJS без ESM export-обвязки.
- Python: обычный entrypoint без лишних импортов.
- PHP: обычный PHP-код без выдуманных контрактов.
- Ruby: корректный def/end без лишних зависимостей.
"""
CASES = [
{
"lang": "nodejs",
"question": "Сделай Fission Node.js функцию: вернуть 200 с приветствием по полю name или по умолчанию. Потом дай код и блок LLM что делает.",
},
{
"lang": "python",
"question": "Юзер сказал только 'сделай функцию, которая возвращает приветствие'. Сделай безопасный вариант для Fission на Python: взять context['name'], если он есть, иначе вернуть приветствие по умолчанию. Сначала нормализуй задачу, потом код, потом блок LLM что делает.",
},
{
"lang": "php",
"question": "Юзер просит Fission-функцию на PHP, но не знает, какой контракт у runtime. Сделай обычный PHP-обработчик: если в context есть name, вернуть JSON 200 с greeting, иначе использовать значение по умолчанию. Не выдумывай фреймворки. Потом код и блок LLM что делает.",
},
{
"lang": "ruby",
"question": "Юзер написал криво: 'нужна функция чтобы приветить человека и не упасть если данных нет'. Сделай Fission-функцию на Ruby: принять context, безопасно достать name, вернуть greeting JSON. Потом код и блок LLM что делает.",
},
]
def post(url: str, payload: dict) -> dict:
body = json.dumps(payload).encode()
req = urllib.request.Request(url, data=body, headers=HEADERS)
try:
with urllib.request.urlopen(req, timeout=90) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as exc:
return {"http_error": exc.code, "body": exc.read().decode(errors="replace")}
def extract_code(answer: str) -> str:
match = re.search(r"```(?:[a-zA-Z0-9_+-]+)?\n(.*?)\n```", answer, re.S)
return match.group(1).strip() if match else ""
def explanation_tail(answer: str) -> str:
pieces = answer.split("```", 2)
if len(pieces) >= 3:
return pieces[2].strip()
return ""
for case in CASES:
ask_resp = post(ASK_API, {"question": PROMPT + "\n\nЗадача:\n" + case["question"]})
if ask_resp.get("http_error"):
print(f"=== {case['lang']} HTTP {ask_resp['http_error']} ===")
print(ask_resp.get("body", ""))
print("\n" + "=" * 72 + "\n")
continue
answer = ask_resp.get("answer", ask_resp.get("error", "???"))
code = extract_code(answer)
explanation = explanation_tail(answer)
check_resp = post(CHECK_API, {"language": case["lang"], "code": code}) if code else {"ok": False, "result": "no code extracted"}
print(f"=== {case['lang']} ===")
print("QUESTION:")
print(case["question"])
print("\nANSWER:")
print(answer)
print("\nEXTRACTED CODE:")
print(code)
print("\nHAS LLM heading:", "LLM что делает" in answer)
print("HAS explanation tail:", bool(explanation))
if explanation:
print(explanation[:800])
print("\nLINT:")
print(json.dumps(check_resp, ensure_ascii=False, indent=2)[:2000])
print("\n" + "=" * 72 + "\n")
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env python3
import json
import re
import urllib.error
import urllib.request
ASK_API = "https://fission.kube5s.ru/console/api/ai/ask"
CHECK_API = "https://fission.kube5s.ru/console/api/ai/check"
HEADERS = {"X-Test-Sub": "livetest@test.local", "Content-Type": "application/json"}
PROMPT = """Ты генерируешь код одной функции для Fission.
Правила:
- Если ввод кривой, сначала коротко нормализуй задачу.
- Если не хватает важного условия, задай один короткий вопрос или опиши допущение.
- Верни только нормализованную задачу, код и отдельный блок LLM что делает.
- Код должен быть синтаксически корректным и соответствовать runtime выбранного языка.
- Не добавляй лишний wrapper-код, лишние зависимости или выдуманные SDK.
- Заголовок блока объяснения должен быть ровно LLM что делает.
Языковые правила:
- Node.js: CommonJS без ESM export-обвязки.
- Python: обычный entrypoint без лишних импортов.
- PHP: обычный PHP-код без выдуманных контрактов.
- Ruby: корректный def/end без лишних зависимостей.
"""
CASES = [
{
"lang": "nodejs",
"question": "Юзер просит 'сделай функцию которая приветит и не упадёт если имя не пришло'. Нужна Fission Node.js функция: принять JSON, взять name если оно есть, иначе использовать default, вернуть 200 и JSON-ответ. Если условия неполные — нормализуй их сам. Потом код и блок LLM что делает.",
},
{
"lang": "nodejs",
"question": "Нужно обработать кривой input: пользователь сказал только 'верни сумму чисел'. Сделай Fission Node.js функцию, которая принимает массив numbers, считает сумму и возвращает JSON с result. Если numbers нет или это не массив — используй пустой массив. Потом код и блок LLM что делает.",
},
{
"lang": "python",
"question": "Юзер хочет Fission-функцию на Python, но сказал только 'преобразуй данные'. Сделай безопасный вариант: если в context есть name, верни greeting; если нет — верни default greeting. Потом код и блок LLM что делает.",
},
{
"lang": "python",
"question": "Нужна функция, которая суммирует числа из payload, но юзер не указал формат. Сделай Fission Python handler: если в context есть numbers, сложи их; если нет — верни 0. Потом код и блок LLM что делает.",
},
{
"lang": "php",
"question": "Юзер криво формулирует задачу: 'нужен JSON и приветствие'. Сделай обычный PHP-обработчик Fission: если в context есть name, вернуть JSON 200 с greeting, иначе default greeting. Потом код и блок LLM что делает.",
},
{
"lang": "php",
"question": "Нужна функция на PHP для подсчёта суммы, но юзер не указал входные данные. Сделай безопасный обработчик: если в context есть numbers и это массив, посчитай сумму, иначе верни 0. Потом код и блок LLM что делает.",
},
{
"lang": "ruby",
"question": "Юзер просит 'сделай что-то с именем, но если его нет — не падай'. Сделай Fission Ruby функцию: безопасно достать name из context, вернуть JSON с greeting, при отсутствии имени использовать default. Потом код и блок LLM что делает.",
},
{
"lang": "ruby",
"question": "Нужна функция на Ruby для суммирования чисел, но вход кривой. Сделай обработчик Fission: взять numbers из context, если это массив — просуммировать, иначе вернуть 0. Потом код и блок LLM что делает.",
},
]
def post(url: str, payload: dict) -> dict:
body = json.dumps(payload).encode()
req = urllib.request.Request(url, data=body, headers=HEADERS)
try:
with urllib.request.urlopen(req, timeout=90) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as exc:
return {"http_error": exc.code, "body": exc.read().decode(errors="replace")}
def extract_code(answer: str) -> str:
match = re.search(r"```(?:[a-zA-Z0-9_+-]+)?\n(.*?)\n```", answer, re.S)
return match.group(1).strip() if match else ""
def explanation_block(answer: str) -> str:
match = re.search(r"(^|\n)LLM что делает\s*\n(.*)$", answer, re.S)
return match.group(2).strip() if match else ""
for case in CASES:
ask_resp = post(ASK_API, {"question": PROMPT + "\n\nЗадача:\n" + case["question"]})
if ask_resp.get("http_error"):
print(f"=== {case['lang']} HTTP {ask_resp['http_error']} ===")
print(ask_resp.get("body", ""))
print("\n" + "=" * 72 + "\n")
continue
answer = ask_resp.get("answer", ask_resp.get("error", "???"))
code = extract_code(answer)
explanation = explanation_block(answer)
lint_resp = post(CHECK_API, {"language": case["lang"], "code": code}) if code else {"ok": False, "result": "no code extracted"}
print(f"=== {case['lang']} ===")
print("QUESTION:")
print(case["question"])
print("\nHAS EXPLICIT HEADING:", "LLM что делает" in answer)
print("HAS PARSED EXPLANATION:", bool(explanation))
print("\nANSWER:")
print(answer)
print("\nEXTRACTED CODE:")
print(code)
if explanation:
print("\nEXPLANATION:")
print(explanation[:800])
print("\nLINT:")
print(json.dumps(lint_resp, ensure_ascii=False, indent=2)[:2000])
print("\n" + "=" * 72 + "\n")
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env python3
import json
import re
import urllib.error
import urllib.request
ASK_API = "https://fission.kube5s.ru/console/api/ai/ask"
CHECK_API = "https://fission.kube5s.ru/console/api/ai/check"
HEADERS = {"X-Test-Sub": "livetest@test.local", "Content-Type": "application/json"}
PROMPT = """Ты генерируешь код одной функции для Fission.
Правила ответа:
- Если ввод кривой, коротко нормализуй задачу.
- Если не хватает важного условия, задай один короткий вопрос или опиши допущение.
- Верни только нормализованную задачу, код и отдельный блок LLM что делает.
- Код должен быть синтаксически корректным и соответствовать runtime выбранного языка.
- Не добавляй лишний wrapper-код, лишние зависимости или выдуманные SDK.
- Раздел LLM что делает должен быть обычным текстом: без **, без markdown-кодблоков, без альтернативных заголовков.
Языковые правила:
- Node.js: CommonJS без ESM export-обвязки.
- Python: обычный entrypoint без лишних импортов.
- PHP: обычный PHP-код без выдуманных контрактов.
- Ruby: корректный def/end без лишних зависимостей.
"""
CASES = [
("nodejs", "Юзер сказал криво: приветствие, default name, не падать на пустом входе. Сделай Fission Node.js handler, который берёт name из JSON и отвечает 200 с greeting."),
("python", "Юзер просит обработать кривой input. Сделай Fission Python handler: если в context есть name, вернуть greeting, иначе default greeting."),
("php", "Юзер хочет JSON и приветствие. Сделай PHP-обработчик Fission: name из context, иначе default, ответ 200 и JSON body."),
("ruby", "Юзер хочет функцию, которая приветствует человека и не падает если name нет. Сделай Ruby handler для Fission с JSON greeting и default name."),
]
def post(url: str, payload: dict) -> dict:
body = json.dumps(payload).encode()
req = urllib.request.Request(url, data=body, headers=HEADERS)
try:
with urllib.request.urlopen(req, timeout=90) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as exc:
return {"http_error": exc.code, "body": exc.read().decode(errors="replace")}
def extract_code(answer: str) -> str:
match = re.search(r"```(?:[a-zA-Z0-9_+-]+)?\n(.*?)\n```", answer, re.S)
return match.group(1).strip() if match else ""
for lang, question in CASES:
ask_resp = post(ASK_API, {"question": PROMPT + "\n\nЗадача:\n" + question})
print(f"=== {lang} ===")
if ask_resp.get("http_error"):
print(f"HTTP {ask_resp['http_error']}")
print(ask_resp.get("body", ""))
print("\n" + "=" * 72 + "\n")
continue
answer = ask_resp.get("answer", ask_resp.get("error", "???"))
code = extract_code(answer)
lint_resp = post(CHECK_API, {"language": lang, "code": code}) if code else {"ok": False, "result": "no code extracted"}
print("QUESTION:")
print(question)
print("\nHAS EXACT HEADING:", bool(re.search(r"(^|\n)LLM что делает\s*$", answer, re.M)))
print("ANSWER:")
print(answer)
print("\nEXTRACTED CODE:")
print(code)
print("\nLINT:")
print(json.dumps(lint_resp, ensure_ascii=False, indent=2)[:2000])
print("\n" + "=" * 72 + "\n")
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env python3
import json
import re
import urllib.error
import urllib.request
ASK_API = "https://fission.kube5s.ru/console/api/ai/ask"
CHECK_API = "https://fission.kube5s.ru/console/api/ai/check"
HEADERS = {"X-Test-Sub": "livetest@test.local", "Content-Type": "application/json"}
PROMPT = """Ты генерируешь код одной функции для Fission.
Нужно отвечать строго в 3 секциях:
1. Нормализованная задача
2. Один fenced code block
3. Заголовок LLM что делает, затем 2-5 коротких пунктов
Правила:
- Если ввод кривой, сначала коротко нормализуй задачу.
- Если не хватает важного условия, задай один короткий вопрос или опиши допущение.
- Не выдумывай лишнюю инфраструктуру, SDK или wrapper-код.
- Код должен быть синтаксически корректным и соответствовать runtime выбранного языка.
- В секции кода используй ровно один fenced block с языковым идентификатором.
- В секции LLM что делает не используй другие заголовки, bold, Explanation или fenced block.
Языковые правила:
- Node.js: CommonJS без ESM export-обвязки.
- Python: обычный entrypoint без лишних импортов.
- PHP: обычный PHP-код без выдуманных контрактов.
- Ruby: корректный def/end без лишних зависимостей.
"""
CASES = [
("nodejs", "Юзер криво сказал: приветствие и не падать если name пустой. Сделай Fission Node.js handler, который читает JSON body, берёт name и отвечает 200 с greeting и default World."),
("nodejs", "Нужна функция для суммы чисел, но формат входа не описан. Сделай Node.js Fission handler: если в JSON есть numbers и это массив, вернуть сумму, иначе 0."),
("python", "Нужна Fission Python функция: если в context есть name, вернуть greeting, иначе default greeting. Ничего лишнего."),
("python", "Юзер просит обработать число и не знает формат. Сделай Python handler для Fission: если в context есть numbers, просуммировать их, иначе вернуть 0."),
("php", "Сделай PHP handler для Fission: name из context, иначе World, ответ JSON 200 с greeting. Без фреймворков."),
("php", "Сделай PHP handler для Fission: если в context есть numbers-массив, вернуть его сумму, иначе 0. Без лишних зависимостей."),
("ruby", "Сделай Ruby handler для Fission: name из context, иначе World, ответ JSON с greeting."),
("ruby", "Сделай Ruby handler для Fission: если в context есть numbers и это массив, вернуть сумму, иначе 0."),
]
def post(url: str, payload: dict) -> dict:
body = json.dumps(payload).encode()
req = urllib.request.Request(url, data=body, headers=HEADERS)
try:
with urllib.request.urlopen(req, timeout=90) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as exc:
return {"http_error": exc.code, "body": exc.read().decode(errors="replace")}
def extract_code(answer: str) -> str:
match = re.search(r"```(?:[a-zA-Z0-9_+-]+)?\n(.*?)\n```", answer, re.S)
return match.group(1).strip() if match else ""
def has_exact_heading(answer: str) -> bool:
return bool(re.search(r"(^|\n)LLM что делает\s*$", answer, re.M))
def explanation_after_heading(answer: str) -> str:
match = re.search(r"(^|\n)LLM что делает\s*\n(.*)$", answer, re.S)
return match.group(2).strip() if match else ""
for lang, question in CASES:
ask_resp = post(ASK_API, {"question": PROMPT + "\n\nЗадача:\n" + question})
print(f"=== {lang} ===")
print("QUESTION:")
print(question)
if ask_resp.get("http_error"):
print(f"HTTP {ask_resp['http_error']}")
print(ask_resp.get("body", ""))
print("\n" + "=" * 72 + "\n")
continue
answer = ask_resp.get("answer", ask_resp.get("error", "???"))
code = extract_code(answer)
lint_resp = post(CHECK_API, {"language": lang, "code": code}) if code else {"ok": False, "result": "no code extracted"}
print("HAS EXACT HEADING:", has_exact_heading(answer))
print("HAS EXPLANATION:", bool(explanation_after_heading(answer)))
print("ANSWER:")
print(answer)
print("\nEXTRACTED CODE:")
print(code)
print("\nLINT:")
print(json.dumps(lint_resp, ensure_ascii=False, indent=2)[:2000])
print("\n" + "=" * 72 + "\n")
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env python3
import json
import re
import urllib.error
import urllib.request
ASK_API = "https://fission.kube5s.ru/console/api/ai/ask"
CHECK_API = "https://fission.kube5s.ru/console/api/ai/check"
HEADERS = {"X-Test-Sub": "livetest@test.local", "Content-Type": "application/json"}
PROMPT = """Ты генерируешь код одной функции для Fission.
Требования к ответу:
- Сначала коротко нормализуй задачу.
- Потом выдай один fenced code block.
- Потом выдай ровно заголовок LLM что делает и 2-5 коротких пунктов.
- Не используй bold, Explanation, английский перевод заголовка, дополнительные заголовки или fenced block вокруг объяснения.
- Не выдумывай лишние SDK, фреймворки или wrapper-код.
- Код должен быть синтаксически корректным для выбранного языка и соответствовать runtime.
Языковые правила:
- Node.js: CommonJS без ESM export-обвязки.
- Python: обычный entrypoint.
- PHP: обычный PHP-код.
- Ruby: корректный def/end.
"""
CASES = [
("nodejs", "Сделай Fission Node.js handler: name из JSON body, иначе World, ответ 200 с greeting."),
("python", "Сделай Fission Python handler: name из context, иначе World, ответ greeting."),
("php", "Сделай Fission PHP handler: name из context, иначе World, ответ 200 JSON greeting."),
("ruby", "Сделай Fission Ruby handler: name из context, иначе World, ответ greeting JSON."),
]
def post(url: str, payload: dict) -> dict:
body = json.dumps(payload).encode()
req = urllib.request.Request(url, data=body, headers=HEADERS)
try:
with urllib.request.urlopen(req, timeout=90) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as exc:
return {"http_error": exc.code, "body": exc.read().decode(errors="replace")}
def extract_code(answer: str) -> str:
match = re.search(r"```(?:[a-zA-Z0-9_+-]+)?\n(.*?)\n```", answer, re.S)
return match.group(1).strip() if match else ""
for lang, question in CASES:
ask_resp = post(ASK_API, {"question": PROMPT + "\n\nЗадача:\n" + question})
print(f"=== {lang} ===")
print("QUESTION:")
print(question)
if ask_resp.get("http_error"):
print(f"HTTP {ask_resp['http_error']}")
print(ask_resp.get("body", ""))
print("\n" + "=" * 72 + "\n")
continue
answer = ask_resp.get("answer", ask_resp.get("error", "???"))
code = extract_code(answer)
lint_resp = post(CHECK_API, {"language": lang, "code": code}) if code else {"ok": False, "result": "no code extracted"}
print("HAS EXACT HEADING:", bool(re.search(r"(^|\n)LLM что делает\s*$", answer, re.M)))
print("ANSWER:")
print(answer)
print("\nEXTRACTED CODE:")
print(code)
print("\nLINT:")
print(json.dumps(lint_resp, ensure_ascii=False, indent=2)[:2000])
print("\n" + "=" * 72 + "\n")
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env python3
import json
import re
import urllib.error
import urllib.request
ASK_API = "https://fission.kube5s.ru/console/api/ai/ask"
CHECK_API = "https://fission.kube5s.ru/console/api/ai/check"
HEADERS = {"X-Test-Sub": "livetest@test.local", "Content-Type": "application/json"}
PROMPT = """Ты генерируешь код одной функции для Fission.
Требования:
- Сначала коротко нормализуй задачу.
- Потом выдай один fenced code block.
- Потом выдай ровно заголовок LLM что делает и 2-5 коротких пунктов.
- Не используй bold, Explanation, английский перевод заголовка, дополнительные заголовки или fenced block вокруг объяснения.
- Не выдумывай лишние SDK, фреймворки или wrapper-код.
- Код должен быть синтаксически корректным для выбранного языка и соответствовать runtime.
Языковые правила:
- Node.js: CommonJS без ESM export-обвязки.
- Python: обычный entrypoint.
- PHP: обычный PHP-код.
- Ruby: корректный def/end.
"""
CASES = [
("nodejs", "Сделай Fission Node.js handler: name из JSON body, иначе World, ответ 200 с greeting."),
("nodejs", "Сделай Fission Node.js handler: если в JSON есть numbers и это массив, вернуть сумму, иначе 0."),
("python", "Сделай Fission Python handler: name из context, иначе World, ответ greeting."),
("python", "Сделай Fission Python handler: если в context есть numbers, вернуть сумму, иначе 0."),
("php", "Сделай Fission PHP handler: name из context, иначе World, ответ 200 JSON greeting."),
("php", "Сделай Fission PHP handler: если в context есть numbers-массив, вернуть его сумму, иначе 0."),
("ruby", "Сделай Fission Ruby handler: name из context, иначе World, ответ greeting JSON."),
("ruby", "Сделай Fission Ruby handler: если в context есть numbers и это массив, вернуть сумму, иначе 0."),
]
def post(url: str, payload: dict) -> dict:
body = json.dumps(payload).encode()
req = urllib.request.Request(url, data=body, headers=HEADERS)
try:
with urllib.request.urlopen(req, timeout=90) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as exc:
return {"http_error": exc.code, "body": exc.read().decode(errors="replace")}
def extract_code(answer: str) -> str:
match = re.search(r"```(?:[a-zA-Z0-9_+-]+)?\n(.*?)\n```", answer, re.S)
return match.group(1).strip() if match else ""
for lang, question in CASES:
ask_resp = post(ASK_API, {"question": PROMPT + "\n\nЗадача:\n" + question})
print(f"=== {lang} ===")
print("QUESTION:")
print(question)
if ask_resp.get("http_error"):
print(f"HTTP {ask_resp['http_error']}")
print(ask_resp.get("body", ""))
print("\n" + "=" * 72 + "\n")
continue
answer = ask_resp.get("answer", ask_resp.get("error", "???"))
code = extract_code(answer)
lint_resp = post(CHECK_API, {"language": lang, "code": code}) if code else {"ok": False, "result": "no code extracted"}
print("HAS EXACT HEADING:", bool(re.search(r"(^|\n)LLM что делает\s*$", answer, re.M)))
print("ANSWER:")
print(answer)
print("\nEXTRACTED CODE:")
print(code)
print("\nLINT:")
print(json.dumps(lint_resp, ensure_ascii=False, indent=2)[:2000])
print("\n" + "=" * 72 + "\n")
@@ -52,6 +52,36 @@ check() {
fi
}
check_error_contains() {
local id="$1"
local desc="$2"
local lang="$3"
local code="$4"
local must_contain="$5"
local escaped
escaped=$(printf '%s' "$code" | python3 -c "import sys,json; print(json.dumps(sys.stdin.read()))")
local body="{\"language\":\"${lang}\",\"code\":${escaped}}"
local resp
resp=$(curl -s -X POST "$API" \
-H "X-Test-Sub: livetest@test.local" \
-H "Content-Type: application/json" \
-d "$body" 2>/dev/null)
local ok_val result_val
ok_val=$(echo "$resp" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('ok','?'))" 2>/dev/null)
result_val=$(echo "$resp" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('result',''))" 2>/dev/null)
if [[ "$ok_val" == "False" ]] && echo "$result_val" | grep -qi "$must_contain"; then
echo "[PASS] $id | $lang | expect error containing=$must_contain | $desc"
else
echo "[FAIL] $id | $lang | expected error containing=$must_contain got=$ok_val | $desc"
echo " resp: $(echo "$result_val" | head -c 160)"
((FAIL++))
fi
}
hallucination_check() {
local id="$1"
local desc="$2"
@@ -116,6 +146,18 @@ check N04 "невалидный JS" nodejs fail \
'const x = @@@;
module.exports = function(ctx) { return x; };'
# ERROR TYPE: unexpected token / bad object literal
check_error_contains N07 "невалидный объектный литерал" nodejs \
'module.exports = function(ctx) {
const data = { a: 1,, b: 2 };
return data;
};' "Unexpected token"
# ERROR TYPE: unterminated function / unexpected end
check_error_contains N08 "незакрытая функция" nodejs \
'module.exports = function(ctx) {
return { status: 200, body: "hello" }' "Unexpected end"
# SYNTAX-ONLY: exports nothing, но синтаксис корректен
check N05 "нет module.exports, но синтаксис корректен" nodejs ok \
'async function doStuff(context) {
@@ -153,6 +195,16 @@ check P04 "невалидный синтаксис Python" python fail \
x = @@@
return x'
# ERROR TYPE: expected ':'
check_error_contains P07 "пропущено двоеточие в сигнатуре" python \
'def main(data)
return "hello"' "expected ':'"
# ERROR TYPE: '(' was never closed
check_error_contains P08 "не закрыта скобка" python \
'def main(data:
return "hello"' "was never closed"
# SYNTAX-ONLY: нет main/handler, но синтаксис корректен
check P05 "нет main/handler, но синтаксис корректен" python ok \
'def process(data):
@@ -225,6 +277,18 @@ check R02 "синтаксическая ошибка Ruby" ruby fail \
{ status: 200 body: "missing comma" }
end'
# ERROR TYPE: unexpected token in hash literal
check_error_contains R06 "неверный hash literal" ruby \
'def handler(context)
{ status: 200 body: "missing comma" }
end' "syntax error"
# ERROR TYPE: unexpected end
check_error_contains R07 "незакрытый вызов/метод" ruby \
'def handler(context)
value = context[:value]
' "unexpected end-of-input"
# SYNTAX-ONLY: нет handler, но синтаксис корректен
check R03 "нет handler, но синтаксис корректен" ruby ok \
'def process(ctx)
@@ -250,6 +314,21 @@ function handler(array $context): array {
return ["status" => 200, "body" => "Hello " . $name];
}'
# ERROR TYPE: unexpected EOF
check_error_contains H06 "неожиданный конец файла" php \
'<?php
function handler(array $context): array {
$name = $context["name"] ?? "World";
' "Unclosed '{'"
# ERROR TYPE: unexpected token
check_error_contains H07 "неверный токен" php \
'<?php
function handler(array $context): array {
$name = @@@;
return ["status" => 200];
}' "unexpected token"
# SYNTAX-ONLY: нет handler, но синтаксис корректен
check H03 "нет функции handler в PHP, но синтаксис корректен" php ok \
'<?php
+1 -1
View File
@@ -49,7 +49,7 @@ spec:
serviceAccountName: fission-console
containers:
- name: console
image: naeel/fission-console@sha256:85361f6fbad506845e0e94b2f9a433b11075fb5f478076270d1d2af7bc5258bb
image: naeel/fission-console:v1.3.3
ports:
- containerPort: 8090
env:
@@ -0,0 +1,70 @@
package api
import (
"fmt"
"time"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)
const (
functionCreatedAtAnnotation = "fission-console/created-at"
functionUpdatedAtAnnotation = "fission-console/updated-at"
)
func ensureFunctionTimestamps(fn *unstructured.Unstructured, now time.Time) {
if fn == nil {
return
}
annotations := fn.GetAnnotations()
if annotations == nil {
annotations = map[string]string{}
}
if annotations[functionCreatedAtAnnotation] == "" {
if created := fn.GetCreationTimestamp(); !created.IsZero() {
annotations[functionCreatedAtAnnotation] = created.UTC().Format(time.RFC3339)
} else {
annotations[functionCreatedAtAnnotation] = now.UTC().Format(time.RFC3339)
}
}
annotations[functionUpdatedAtAnnotation] = now.UTC().Format(time.RFC3339)
fn.SetAnnotations(annotations)
}
func functionTimestampResponse(fn *unstructured.Unstructured) map[string]string {
result := map[string]string{}
if fn == nil {
return result
}
annotations := fn.GetAnnotations()
if annotations == nil {
annotations = map[string]string{}
}
if created := annotations[functionCreatedAtAnnotation]; created != "" {
result["created_at"] = created
} else if ts := fn.GetCreationTimestamp(); !ts.IsZero() {
result["created_at"] = ts.UTC().Format(time.RFC3339)
}
if updated := annotations[functionUpdatedAtAnnotation]; updated != "" {
result["updated_at"] = updated
} else if v := result["created_at"]; v != "" {
result["updated_at"] = v
}
return result
}
func formatRFC3339Now(now time.Time) string {
return now.UTC().Format(time.RFC3339)
}
func parseRFC3339(value string) (time.Time, error) {
return time.Parse(time.RFC3339, value)
}
func mustParseRFC3339(value string) time.Time {
parsed, err := parseRFC3339(value)
if err != nil {
panic(fmt.Sprintf("parse RFC3339 %q: %v", value, err))
}
return parsed
}
@@ -0,0 +1,88 @@
package api
import (
"testing"
"time"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)
func TestEnsureFunctionTimestampsSetsBothOnCreate(t *testing.T) {
fn := &unstructured.Unstructured{}
now := mustParseRFC3339("2026-04-28T10:11:12Z")
ensureFunctionTimestamps(fn, now)
ann := fn.GetAnnotations()
if ann[functionCreatedAtAnnotation] != now.Format(time.RFC3339) {
t.Fatalf("created_at = %q, want %q", ann[functionCreatedAtAnnotation], now.Format(time.RFC3339))
}
if ann[functionUpdatedAtAnnotation] != now.Format(time.RFC3339) {
t.Fatalf("updated_at = %q, want %q", ann[functionUpdatedAtAnnotation], now.Format(time.RFC3339))
}
}
func TestEnsureFunctionTimestampsPreservesExistingCreatedAt(t *testing.T) {
fn := &unstructured.Unstructured{}
created := "2026-04-27T09:10:11Z"
annotations := map[string]string{functionCreatedAtAnnotation: created}
fn.SetAnnotations(annotations)
now := mustParseRFC3339("2026-04-28T10:11:12Z")
ensureFunctionTimestamps(fn, now)
ann := fn.GetAnnotations()
if ann[functionCreatedAtAnnotation] != created {
t.Fatalf("created_at = %q, want %q", ann[functionCreatedAtAnnotation], created)
}
if ann[functionUpdatedAtAnnotation] != now.Format(time.RFC3339) {
t.Fatalf("updated_at = %q, want %q", ann[functionUpdatedAtAnnotation], now.Format(time.RFC3339))
}
}
func TestEnsureFunctionTimestampsBackfillsCreatedAtFromCreationTimestamp(t *testing.T) {
fn := &unstructured.Unstructured{}
fn.SetCreationTimestamp(metav1.NewTime(mustParseRFC3339("2026-04-26T08:09:10Z")))
now := mustParseRFC3339("2026-04-28T10:11:12Z")
ensureFunctionTimestamps(fn, now)
ann := fn.GetAnnotations()
if ann[functionCreatedAtAnnotation] != "2026-04-26T08:09:10Z" {
t.Fatalf("created_at = %q", ann[functionCreatedAtAnnotation])
}
if ann[functionUpdatedAtAnnotation] != now.Format(time.RFC3339) {
t.Fatalf("updated_at = %q, want %q", ann[functionUpdatedAtAnnotation], now.Format(time.RFC3339))
}
}
func TestFunctionTimestampResponse(t *testing.T) {
fn := &unstructured.Unstructured{}
annotations := map[string]string{
functionCreatedAtAnnotation: "2026-04-26T08:09:10Z",
functionUpdatedAtAnnotation: "2026-04-28T10:11:12Z",
}
fn.SetAnnotations(annotations)
resp := functionTimestampResponse(fn)
if resp["created_at"] != "2026-04-26T08:09:10Z" {
t.Fatalf("created_at = %q", resp["created_at"])
}
if resp["updated_at"] != "2026-04-28T10:11:12Z" {
t.Fatalf("updated_at = %q", resp["updated_at"])
}
}
func TestFunctionTimestampResponseFallsBackToCreationTimestamp(t *testing.T) {
fn := &unstructured.Unstructured{}
fn.SetCreationTimestamp(metav1.NewTime(mustParseRFC3339("2026-04-26T08:09:10Z")))
resp := functionTimestampResponse(fn)
if resp["created_at"] != "2026-04-26T08:09:10Z" {
t.Fatalf("created_at = %q", resp["created_at"])
}
if resp["updated_at"] != "2026-04-26T08:09:10Z" {
t.Fatalf("updated_at = %q", resp["updated_at"])
}
}
+71 -37
View File
@@ -354,6 +354,9 @@ func (s *Server) handleCreateFunction(w http.ResponseWriter, r *http.Request) {
fnAnnotations := map[string]any{
"fission-console/language": req.Language,
}
now := time.Now().UTC()
fnAnnotations[functionCreatedAtAnnotation] = now.Format(time.RFC3339)
fnAnnotations[functionUpdatedAtAnnotation] = now.Format(time.RFC3339)
if req.TTL != "" {
expiresAt, ttlErr := parseTTL(req.TTL)
if ttlErr != nil {
@@ -381,7 +384,7 @@ func (s *Server) handleCreateFunction(w http.ResponseWriter, r *http.Request) {
"kind": "Function",
"metadata": map[string]any{"name": req.Name, "namespace": ns, "annotations": fnAnnotations},
"spec": map[string]any{
"environment": map[string]any{"name": req.Environment, "namespace": ns},
"environment": map[string]any{"name": req.Environment, "namespace": ns},
"functionTimeout": req.Timeout,
"InvokeStrategy": map[string]any{
"ExecutionStrategy": map[string]any{"ExecutorType": "poolmgr"},
@@ -490,6 +493,8 @@ func (s *Server) handleGetFunction(w http.ResponseWriter, r *http.Request, name
"package": packageName,
"entrypoint": entrypoint,
"timeout": functionTimeout,
"created_at": functionTimestampResponse(fn)["created_at"],
"updated_at": functionTimestampResponse(fn)["updated_at"],
"code": code,
"route": route,
"methods": methods,
@@ -498,11 +503,9 @@ func (s *Server) handleGetFunction(w http.ResponseWriter, r *http.Request, name
}
// handleUpdateFunctionCode обновляет код уже существующей функции.
// Обновляет Package.spec.deployment.literal и синхронизирует resourceVersion в Function.
//
// Почему нужно обновлять resourceVersion в Function:
// Fission executor кэширует Package по resourceVersion. Без обновления в Function
// executor будет использовать старый код до перезапуска pod.
// Создаёт НОВЫЙ Package (вместо обновления старого) чтобы executor сбросил кэш:
// executor кэширует function service по functionUid и не видит изменений в том же Package.
// Новое имя пакета гарантирует cache miss в executor.
func (s *Server) handleUpdateFunctionCode(w http.ResponseWriter, r *http.Request, name string) {
var req model.UpdateCodeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -529,21 +532,7 @@ func (s *Server) handleUpdateFunctionCode(w http.ResponseWriter, r *http.Request
return
}
pkgName, _, _ := unstructured.NestedString(fn.Object, "spec", "package", "packageref", "name")
if pkgName == "" {
writeJSONError(w, http.StatusBadGateway, "function has no package reference")
return
}
pkg, err := s.dyn.Resource(fission.PackageGVR).Namespace(ns).Get(ctx, pkgName, metav1.GetOptions{})
if err != nil {
status := http.StatusBadGateway
if apierrors.IsNotFound(err) {
status = http.StatusNotFound
}
writeJSONError(w, status, fmt.Sprintf("get package %q: %v", pkgName, err))
return
}
oldPkgName, _, _ := unstructured.NestedString(fn.Object, "spec", "package", "packageref", "name")
// Определяем язык из аннотации — нужен для правильной упаковки
lang, _, _ := unstructured.NestedString(fn.Object, "metadata", "annotations", "fission-console/language")
@@ -553,38 +542,83 @@ func (s *Server) handleUpdateFunctionCode(w http.ResponseWriter, r *http.Request
return
}
literal := base64.StdEncoding.EncodeToString(deployBytes)
if err := unstructured.SetNestedField(pkg.Object, literal, "spec", "deployment", "literal"); err != nil {
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("set package literal: %v", err))
return
}
// Создаём новый Package с уникальным именем.
// Это единственный способ сбросить кэш executor: он кэширует по functionUid и
// не замечает изменений в существующем Package.
envName, _, _ := unstructured.NestedString(fn.Object, "spec", "environment", "name")
createdAt := func() time.Time {
ann := fn.GetAnnotations()
if ann != nil {
if v := strings.TrimSpace(ann[functionCreatedAtAnnotation]); v != "" {
if ts, err := parseRFC3339(v); err == nil {
return ts.UTC()
}
}
}
if ts := fn.GetCreationTimestamp(); !ts.IsZero() {
return ts.UTC()
}
return time.Time{}
}()
now := time.Now().UTC()
newPkgName := name + "-pkg-" + strconv.FormatInt(time.Now().UnixMilli(), 36)
newPkg := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "fission.io/v1",
"kind": "Package",
"metadata": map[string]any{"name": newPkgName, "namespace": ns},
"spec": map[string]any{
"deployment": map[string]any{"type": "literal", "literal": base64.StdEncoding.EncodeToString(deployBytes)},
"environment": map[string]any{"name": envName, "namespace": ns},
"source": map[string]any{},
},
}}
updatedPkg, err := s.dyn.Resource(fission.PackageGVR).Namespace(ns).Update(ctx, pkg, metav1.UpdateOptions{})
createdPkg, err := s.dyn.Resource(fission.PackageGVR).Namespace(ns).Create(ctx, newPkg, metav1.CreateOptions{})
if err != nil {
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("update package %q: %v", pkgName, err))
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create new package: %v", err))
return
}
// Обновляем Function на новый Package
if err := unstructured.SetNestedField(fn.Object, normalizeFunctionTimeout(req.Timeout), "spec", "functionTimeout"); err != nil {
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("set function timeout: %v", err))
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, newPkgName, metav1.DeleteOptions{})
return
}
// Синхронизируем resourceVersion в Function.spec.package.packageref
// Это триггерит executor перезагрузить код в pool pod
if err := unstructured.SetNestedField(fn.Object, updatedPkg.GetResourceVersion(), "spec", "package", "packageref", "resourceversion"); err != nil {
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("set function package resourceversion: %v", err))
ensureFunctionTimestamps(fn, now)
if createdAt.IsZero() {
createdAt = now
}
fnAnnotations := fn.GetAnnotations()
if fnAnnotations == nil {
fnAnnotations = map[string]string{}
}
fnAnnotations[functionCreatedAtAnnotation] = createdAt.UTC().Format(time.RFC3339)
fnAnnotations[functionUpdatedAtAnnotation] = now.Format(time.RFC3339)
fn.SetAnnotations(fnAnnotations)
if err := unstructured.SetNestedField(fn.Object, map[string]any{
"name": newPkgName,
"namespace": ns,
"resourceversion": createdPkg.GetResourceVersion(),
}, "spec", "package", "packageref"); err != nil {
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("set function packageref: %v", err))
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, newPkgName, metav1.DeleteOptions{})
return
}
if _, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Update(ctx, fn, metav1.UpdateOptions{}); err != nil {
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("update function %q package ref: %v", name, err))
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("update function %q: %v", name, err))
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, newPkgName, metav1.DeleteOptions{})
return
}
// Удаляем старый Package (best effort)
if oldPkgName != "" && oldPkgName != newPkgName {
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, oldPkgName, metav1.DeleteOptions{})
}
writeAnyJSON(w, http.StatusOK, map[string]any{
"updated": true,
"package": pkgName,
"package_resourceversion": updatedPkg.GetResourceVersion(),
"updated": true,
"package": newPkgName,
})
}
+29
View File
@@ -5,8 +5,10 @@ import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"regexp"
"sort"
"strings"
"unicode/utf8"
@@ -90,6 +92,9 @@ func decodeArchiveBytesToSource(decoded []byte) (string, error) {
if len(decoded) == 0 {
return "", io.ErrUnexpectedEOF
}
if src, ok := decodeNodeJSWrapperSource(decoded); ok {
return src, nil
}
// Если байты — валидный UTF-8, возвращаем напрямую (python, ruby, php)
if utf8.Valid(decoded) {
return string(decoded), nil
@@ -103,6 +108,24 @@ func decodeArchiveBytesToSource(decoded []byte) (string, error) {
return "", io.ErrUnexpectedEOF
}
var nodeJSWrapperPattern = regexp.MustCompile(`(?s)^const __mod = \{ exports: \{\} \};\s*\(new Function\('module', 'exports', (.+?)\)\)\(__mod, __mod\.exports\);\s*const _fn = __mod\.exports;`)
func decodeNodeJSWrapperSource(decoded []byte) (string, bool) {
text := string(decoded)
matches := nodeJSWrapperPattern.FindStringSubmatch(text)
if len(matches) != 2 {
return "", false
}
var code string
if err := json.Unmarshal([]byte(matches[1]), &code); err != nil {
return "", false
}
if strings.TrimSpace(code) == "" {
return "", false
}
return code, true
}
// decodeZipSource извлекает исходный UTF-8 файл из zip архива.
// Предпочитает хорошо известные имена файлов (main.py, handler.go и т.д.).
func decodeZipSource(zipBytes []byte) (string, error) {
@@ -118,6 +141,9 @@ func decodeZipSource(zipBytes []byte) (string, error) {
if strings.EqualFold(file.Name, name) {
content, readErr := readZipFile(file)
if readErr == nil && utf8.Valid(content) {
if src, ok := decodeNodeJSWrapperSource(content); ok {
return src, nil
}
return string(content), nil
}
}
@@ -136,6 +162,9 @@ func decodeZipSource(zipBytes []byte) (string, error) {
for _, file := range files {
content, readErr := readZipFile(file)
if readErr == nil && utf8.Valid(content) {
if src, ok := decodeNodeJSWrapperSource(content); ok {
return src, nil
}
return string(content), nil
}
}
+98
View File
@@ -0,0 +1,98 @@
package api
import (
"fission-console/internal/runtime"
"testing"
)
func TestDecodeNodeJSWrapperSource(t *testing.T) {
wrapper := []byte("const __mod = { exports: {} };\n(new Function('module', 'exports', \"module.exports = async function () { return { status: 200, body: \\\"ok\\\" }; }\"))(__mod, __mod.exports);\nconst _fn = __mod.exports;")
src, ok := decodeNodeJSWrapperSource(wrapper)
if !ok {
t.Fatalf("decodeNodeJSWrapperSource() = false, want true")
}
want := "module.exports = async function () { return { status: 200, body: \"ok\" }; }"
if src != want {
t.Fatalf("decoded source = %q, want %q", src, want)
}
}
func TestDecodeArchiveBytesToSourcePrefersNodeJSWrapperExtraction(t *testing.T) {
wrapper := []byte("const __mod = { exports: {} };\n(new Function('module', 'exports', \"module.exports = async function () { return { status: 200, body: \\\"ok\\\" }; }\"))(__mod, __mod.exports);\nconst _fn = __mod.exports;\n\n\tasync function __invoke(ctx) {\n const fn = typeof _fn === 'function' ? _fn : (_fn.default || _fn.handler || _fn.main);\n if (!fn) throw new Error('no exported function found in user code');\n const result = await fn(ctx);\n if (!result) return { status: 200, body: '' };\n if (typeof result.status !== 'undefined') return result;\n return { status: 200, ...result };\n}\n\n\texport { __invoke as main, __invoke as handler };\n\texport default __invoke;\n")
src, err := decodeArchiveBytesToSource(wrapper)
if err != nil {
t.Fatalf("decodeArchiveBytesToSource() error = %v", err)
}
want := "module.exports = async function () { return { status: 200, body: \"ok\" }; }"
if src != want {
t.Fatalf("decoded source = %q, want %q", src, want)
}
}
func TestDecodeArchiveBytesToSourcePrefersNodeJSWrapperExtractionWithNewlines(t *testing.T) {
wrapper := []byte("const __mod = { exports: {} };\n(new Function('module', 'exports', \"module.exports = async function (context) {\\n const value = await Promise.resolve(context?.value || 'ok');\\n return { status: 200, body: value };\\n}\"))(__mod, __mod.exports);\nconst _fn = __mod.exports;\n\n\tasync function __invoke(ctx) {\n const fn = typeof _fn === 'function' ? _fn : (_fn.default || _fn.handler || _fn.main);\n if (!fn) throw new Error('no exported function found in user code');\n const result = await fn(ctx);\n if (!result) return { status: 200, body: '' };\n if (typeof result.status !== 'undefined') return result;\n return { status: 200, ...result };\n}\n\n\texport { __invoke as main, __invoke as handler };\n\texport default __invoke;\n")
src, err := decodeArchiveBytesToSource(wrapper)
if err != nil {
t.Fatalf("decodeArchiveBytesToSource() error = %v", err)
}
want := "module.exports = async function (context) {\n const value = await Promise.resolve(context?.value || 'ok');\n return { status: 200, body: value };\n}"
if src != want {
t.Fatalf("decoded source = %q, want %q", src, want)
}
}
func TestDecodeArchiveBytesToSourceKeepsPythonMultilineSource(t *testing.T) {
src := "def main(context):\n value = context.get('value', 'ok')\n return {'status': 200, 'body': value}\n"
got, err := decodeArchiveBytesToSource([]byte(src))
if err != nil {
t.Fatalf("decodeArchiveBytesToSource() error = %v", err)
}
if got != src {
t.Fatalf("decoded source = %q, want %q", got, src)
}
}
func TestDecodeArchiveBytesToSourceUnwrapsNodeJSZipPayload(t *testing.T) {
zipBytes, err := runtime.BuildJSDeployZip(`module.exports = async function (context) {
return { status: 200, body: context?.value || "ok" };
}`)
if err != nil {
t.Fatalf("runtime.BuildJSDeployZip() error = %v", err)
}
got, err := decodeArchiveBytesToSource(zipBytes)
if err != nil {
t.Fatalf("decodeArchiveBytesToSource() error = %v", err)
}
want := `module.exports = async function (context) {
return { status: 200, body: context?.value || "ok" };
}`
if got != want {
t.Fatalf("decoded source = %q, want %q", got, want)
}
}
func TestDecodeArchiveBytesToSourceKeepsRubyMultilineSource(t *testing.T) {
src := "def handler(context)\n value = context[:value] || 'ok'\n { status: 200, body: value }\nend\n"
got, err := decodeArchiveBytesToSource([]byte(src))
if err != nil {
t.Fatalf("decodeArchiveBytesToSource() error = %v", err)
}
if got != src {
t.Fatalf("decoded source = %q, want %q", got, src)
}
}
func TestDecodeArchiveBytesToSourceKeepsPHPMultilineSource(t *testing.T) {
src := "<?php\nfunction handler(array $context): array {\n $value = $context['value'] ?? 'ok';\n return ['status' => 200, 'body' => $value];\n}\n"
got, err := decodeArchiveBytesToSource([]byte(src))
if err != nil {
t.Fatalf("decodeArchiveBytesToSource() error = %v", err)
}
if got != src {
t.Fatalf("decoded source = %q, want %q", got, src)
}
}
+10 -31
View File
@@ -9,24 +9,10 @@ import (
// BuildJSDeployZip упаковывает Node.js код в zip для Fission deployment package.
//
// Почему zip, а не просто literal-код:
// Fission node-env v3 поддерживает ESM модули. Чтобы Node.js трактовал файл
// как ESM, нужен package.json с {"type":"module"} рядом с main.js.
// Без него `import` синтаксис вызовет "require is not defined" или SyntaxError.
//
// Почему new Function:
// Пользовательский код пишет CJS-стиль (module.exports = ...) но мы хотим ESM wrapper.
// new Function() изолирует module/exports от глобального ESM контекста и позволяет
// исполнять CJS-код без изменений.
//
// Результат: zip с двумя файлами:
// - package.json: {"type":"module"}
// - main.js: ESM wrapper + инлайн пользовательский код через new Function
//
// Совместимость entrypoint:
// UI исторически отправлял entrypoint="handler", а backend по умолчанию использует
// entrypoint="main". Чтобы specialization не ломался из-за несовпадения,
// wrapper экспортирует обе точки входа: named exports main и handler, а также default.
// Генерируем CommonJS wrapper: так Fission/node executor не упирается в ESM export
// синтаксис и может исполнять результат без package.json и type=module.
// Пользовательский код остаётся CJS-стилем через new Function(module, exports).
// Wrapper экспортирует main, handler и default через module.exports.
func BuildJSDeployZip(code string) ([]byte, error) {
// Сериализуем пользовательский код в JSON строку чтобы безопасно инлайнить
// в JavaScript-литерал — экранирует кавычки, переводы строк, спецсимволы.
@@ -35,10 +21,10 @@ func BuildJSDeployZip(code string) ([]byte, error) {
return nil, fmt.Errorf("marshal user code: %w", err)
}
// main.js — ESM wrapper:
// main.js — CommonJS wrapper:
// 1. new Function создаёт функцию в пустом модульном контексте (нет import/export)
// 2. Передаём ей module и exports как параметры → пользовательский CJS код работает
// 3. Экспортируем default async function для Fission node-env entrypoint
// 3. Экспортируем main/handler/default через module.exports
wrapper := fmt.Sprintf(`const __mod = { exports: {} };
(new Function('module', 'exports', %s))(__mod, __mod.exports);
const _fn = __mod.exports;
@@ -52,22 +38,15 @@ const _fn = __mod.exports;
return { status: 200, ...result };
}
export { __invoke as main, __invoke as handler };
export default __invoke;
module.exports = __invoke;
module.exports.main = __invoke;
module.exports.handler = __invoke;
module.exports.default = __invoke;
`, string(codeJSON))
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
// package.json: включает ESM режим для всего архива
pkgfw, err := zw.Create("package.json")
if err != nil {
return nil, err
}
if _, err := pkgfw.Write([]byte(`{"type":"module"}`)); err != nil {
return nil, err
}
fw, err := zw.Create("main.js")
if err != nil {
return nil, err
+31 -3
View File
@@ -451,6 +451,8 @@
<th>Имя</th>
<th>Окружение</th>
<th>Пакет</th>
<th>Создана</th>
<th>Изменена</th>
<th>Маршрут</th>
<th>Методы</th>
<th class="nowrap">Действия</th>
@@ -808,6 +810,25 @@
.replaceAll("'", '&#39;');
}
function formatTimestamp(ts) {
if (!ts) return '—';
var d = new Date(ts);
if (isNaN(d.getTime())) return String(ts);
return d.toLocaleString('ru-RU', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
}
function timestampCell(ts) {
var text = formatTimestamp(ts);
return '<span class="mono" title="' + h(ts || '') + '">' + h(text) + '</span>';
}
const LANG_TEMPLATES = {
python: {
entrypoint: 'main.main',
@@ -936,6 +957,7 @@
});
closeEdit();
progress.stop('\u041a\u043e\u0434 \u043e\u0431\u043d\u043e\u0432\u043b\u0451\u043d: ' + name, 'ok');
await reloadAll();
} catch (e) {
progress.stop('\u041e\u0448\u0438\u0431\u043a\u0430 \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u044f: ' + e.message, 'err');
} finally {
@@ -1043,6 +1065,8 @@
const rows = (fns || []).map(function (f) {
const spec = f.spec || {};
const meta = f.metadata || {};
const ann = meta.annotations || {};
const env = (spec.environment && spec.environment.name) || '-';
const pkg = (spec.package && spec.package.packageref && spec.package.packageref.name) || '-';
const name = (f.metadata && f.metadata.name) || '-';
@@ -1050,6 +1074,8 @@
const route = (trig.spec && trig.spec.relativeurl) || '-';
const methods = (trig.spec && trig.spec.methods) || [];
const chips = methods.map(function (m) { return '<span class="chip">' + h(m) + '</span>'; }).join('');
const createdAt = ann['fission-console/created-at'] || meta.creationTimestamp || '-';
const updatedAt = ann['fission-console/updated-at'] || createdAt;
var isGo = /go[-_]env/.test(env);
var isTf = /^tf-/.test(name);
var tfBadge = (isGo || isTf) ? '<span class="chip" style="background:#555;color:#ffa" title="Управляется Terraform. Изменения могут быть перезаписаны при terraform apply.">TF</span> ' : '';
@@ -1061,18 +1087,20 @@
'<td class="mono">' + h(name) + '</td>' +
'<td>' + h(env) + '</td>' +
'<td class="mono">' + h(pkg) + '</td>' +
'<td>' + timestampCell(createdAt) + '</td>' +
'<td>' + timestampCell(updatedAt) + '</td>' +
'<td class="mono">' + h(route) + '</td>' +
'<td>' + chips + '</td>' +
'<td class="nowrap">' + actions + '</td>' +
'</tr>';
}).join('');
document.getElementById('fn-rows').innerHTML = rows || '<tr><td colspan="3">\u041d\u0435\u0442 \u0444\u0443\u043d\u043a\u0446\u0438\u0439</td></tr>';
document.getElementById('fn-rows').innerHTML = rows || '<tr><td colspan="8">\u041d\u0435\u0442 \u0444\u0443\u043d\u043a\u0446\u0438\u0439</td></tr>';
if (!rows) {
document.getElementById('fn-rows').innerHTML = '<tr><td colspan="6">\u041d\u0435\u0442 \u0444\u0443\u043d\u043a\u0446\u0438\u0439</td></tr>';
document.getElementById('fn-rows').innerHTML = '<tr><td colspan="8">\u041d\u0435\u0442 \u0444\u0443\u043d\u043a\u0446\u0438\u0439</td></tr>';
}
} catch (e) {
document.getElementById('fn-rows').innerHTML = '<tr><td colspan="6">Load error: ' + e.message + '</td></tr>';
document.getElementById('fn-rows').innerHTML = '<tr><td colspan="8">Load error: ' + e.message + '</td></tr>';
showStatus('\u041e\u0448\u0438\u0431\u043a\u0430 \u0437\u0430\u0433\u0440\u0443\u0437\u043a\u0438: ' + e.message, 'err');
}
}