cron_webhooks
Cron Webhooks: дизайн-документ
Цель
Cron webhooks вызывают внешние агенты по расписанию. Агент получает POST-запрос с инструкцией, может вернуть данные (новые/обновленные заметки) в синхронном режиме или работать асинхронно через shortapitoken.
Предполагается что на стороне получателя — MCP-инструмент или агент, который может генерировать заметки, дайджесты, отчёты.
Общая инфраструктура (shortapitoken, HMAC, agent response, retry, debug endpoints) описана в docs/shared_webhooks.md.
Сценарий использования
- Админ создает cron webhook: URL + cron schedule + instruction
- По расписанию система запускает cron job
- Job создает delivery запись, enqueue'ит в goqite
- Background worker отправляет POST с инструкцией и опциональным shortapitoken
- Агент обрабатывает запрос:
- Синхронно: возвращает changes в response body -> сервер импортирует через InsertNote
- Асинхронно: возвращает 202 Accepted -> работает через API, пушит изменения через shortapitoken
- Сервер сохраняет результат delivery (статус, время выполнения, ответ)
Таблицы
cron_webhooks
create table cron_webhooks (
id integer primary key autoincrement,
url text not null,
cron_schedule text not null, -- cron expression: "0 9 * * *"
instruction text not null default '', -- инструкция для агента
secret text not null, -- HMAC secret, автогенерируется если не задан
pass_api_key boolean not null default false, -- генерировать shortapitoken
timeout_seconds integer not null default 60, -- таймаут HTTP ответа
max_depth integer not null default 1, -- защита от рекурсии для агентных пушей
max_retries integer not null default 0, -- retry при ошибках agent response
next_run_at datetime, -- следующее время запуска (вычисляется из cron_schedule)
read_patterns text not null default '["*"]', -- JSON array glob patterns для чтения агентом
write_patterns text not null default '[]', -- JSON array glob patterns для записи агентом
enabled boolean not null default true,
description text not null default '',
created_at datetime not null default (datetime('now')),
created_by integer not null references admins(user_id) on delete restrict,
updated_at datetime not null default (datetime('now')),
disabled_at datetime,
disabled_by integer references admins(user_id) on delete restrict
);
Заметки:
cron_schedule— стандартный cron expression:"0 9 * * *"(каждый день в 9:00)instruction— текстовая инструкция, которую получит агент. Например: "Сгенерируй дайджест за последний день"secret— всегда задан. Автогенерируется при создании если не указан вручную. Подробности HMAC подписи — см. shared_webhooks.md.pass_api_key— если true, в payload включается shortapitoken (JWT, TTL из appconfig, read+write доступ к API, depth+1). Подробности shortapitoken — см. shared_webhooks.md.timeout_seconds— таймаут ожидания HTTP ответа (default 60s, можно увеличить для тяжелых задач)max_depth— контроль рекурсии. Если агент пушит заметки через API, применяется depth checking (как в change webhooks). Подробности — см. shared_webhooks.md.max_retries— unified retry: если > 0 и agent response вызвал ошибки, delivery переотправляется с информацией об ошибке в payload. Default: 0. goqite MaxReceive не используется для retry — толькоmax_retries. Подробности — см. shared_webhooks.md.next_run_at— следующее время запуска. Вычисляется изcron_scheduleпри создании и после каждого запуска. Системный cron проверяетwhere next_run_at <= datetime('now').read_patterns— glob patterns для чтения. Default["*"]. Передаются в shortapitoken JWT.write_patterns— glob patterns для записи. Default[](безопасный default). Передаются в shortapitoken JWT.
cron_webhook_deliveries
create table cron_webhook_deliveries (
id integer primary key autoincrement,
cron_webhook_id integer not null references cron_webhooks(id) on delete cascade,
status text not null default 'pending', -- pending, success, failed
response_status integer, -- HTTP status code ответа
attempt integer not null default 1, -- номер попытки
duration_ms integer, -- время ответа в мс
created_at datetime not null default (datetime('now')),
completed_at datetime
);
Заметки:
- Структура идентична
webhook_deliveriesиз change webhooks - Тяжёлые данные (request_body, response_body, error_message) хранятся в
webhook_delivery_logs— см. shared_webhooks.md. - Индексы:
(cron_webhook_id, created_at)для истории
Payload
HTTP-запрос TO агента
POST {cron_webhook.url}
Content-Type: application/json
User-Agent: trip2g-webhooks/1.0
X-Webhook-ID: {delivery.id}
X-Webhook-Timestamp: {unix_timestamp}
X-Webhook-Signature: sha256={hmac_hex}
X-Webhook-Attempt: {attempt_number}
X-Webhook-Signature присутствует всегда (secret автогенерируется). Подробности HMAC — см. shared_webhooks.md.
Body
{
"version": 1,
"id": 42,
"timestamp": 1738000000,
"attempt": 1,
"instruction": "Сгенерируй дайджест за последний день",
"response_schema": {
"type": "object",
"properties": {
"status": {"type": "string"},
"message": {"type": "string"},
"changes": {
"type": "array",
"items": {
"type": "object",
"properties": {
"path": {"type": "string"},
"content": {"type": "string"},
"expected_hash": {"type": "string"}
}
}
}
}
},
"api_token": "eyJhbGc..."
}
Поля:
version— версия формата payload (текущая: 1)id— ID доставки для дедупликацииtimestamp— Unix время созданияattempt— номер попытки (1, 2, 3)instruction— текстовая инструкция изcron_webhooks.instructionresponse_schema— серверная константа (не хранится в БД), описывает ожидаемый формат ответа агента. Сервер включает её в каждый payload, чтобы агент знал какой формат возвращатьapi_token— shortapitoken JWT (только еслиpass_api_key=true). Read+write доступ (TTL из appconfig, по умолчанию 60 мин), depth+1 в claims
Retry payload (attempt > 1)
При retry payload включает previous_error:
{
"version": 1,
"id": 42,
"timestamp": 1738000000,
"attempt": 2,
"instruction": "Сгенерируй дайджест за последний день",
"previous_error": "expected_hash mismatch for digests/2026-02-09.md",
"response_schema": {
"type": "object",
"properties": {
"status": {"type": "string"},
"message": {"type": "string"},
"changes": {
"type": "array",
"items": {
"type": "object",
"properties": {
"path": {"type": "string"},
"content": {"type": "string"},
"expected_hash": {"type": "string"}
}
}
}
}
},
"api_token": "eyJhbGc..."
}
Ответ агента (синхронный режим)
Подробности формата agent response, обработки changes, валидации и retry — см. shared_webhooks.md.
{
"status": "ok",
"message": "Generated 1 digest",
"changes": [
{
"path": "digests/2026-02-09.md",
"content": "# Дайджест за 2026-02-09\n\n...",
"expected_hash": ""
}
]
}
Поля:
status— статус выполнения (ok,error,partial)message— сообщение для лога (опционально)changes— массив изменений (опционально). Если присутствует — сервер импортирует через InsertNote:path— путь заметки (обязательное поле)content— содержимое заметки (обязательное поле)expected_hash— для обновлений — проверить противlatest_content_hash. Пустая строка для новых файлов
Валидация:
- Сервер не выполняет runtime JSON Schema валидацию
- Валидация через ozzo-validation на Go struct (обязательные поля:
path,content) response_schemaв payload — серверная константа, только документация для агента
Асинхронный режим
Агент может вернуть 202 Accepted и работать через API:
{
"status": "accepted",
"message": "Processing started, will push results via API"
}
Агент использует api_token из payload для вызова API (pushNotes, commitNotes).
Режимы работы: Sync vs Async
Синхронный режим
- Агент получает POST
- Обрабатывает запрос (генерация, AI, вычисления)
- Возвращает
changesв response body - Сервер импортирует изменения через InsertNote
- Delivery записывается как
success
Плюсы: Простота, атомарность
Минусы: Ограничен таймаутом (default 60s)
Асинхронный режим
- Агент получает POST
- Возвращает
202 Acceptedсразу - Запускает фоновую обработку
- Пушит изменения через API (
api_token) - Delivery записывается как
success(агент принял задачу)
Плюсы: Нет ограничения по времени, можно делать долгие задачи
Минусы: Нет гарантии что агент завершит работу
Таймауты
timeout_seconds— конфигурируемый (default 60s)- Для тяжелых задач (AI генерация, большие вычисления) — можно увеличить до 300s или работать асинхронно
Cron execution
Архитектура: system cron + next_run_at
System cron (cmd/server/cronjobs.go) — каждую минуту
│
├── select * from cron_webhooks
│ where enabled = true
│ and disabled_at is null
│ and next_run_at <= datetime('now')
│
└── Для каждого cron_webhook (в транзакции):
├── Обновить next_run_at (следующее время по cron_schedule)
├── Создать delivery запись (status=pending)
└── Enqueue goqite job: deliver_cron_webhook(delivery_id)
Как это работает
Существующий системный cron job (в cmd/server/cronjobs.go) запускается каждую минуту. Job executecronwebhooks:
- Выполняет запрос
ListCronWebhooksDueForExecution— все enabled вебхуки сnext_run_at <= datetime('now') - Для каждого найденного вебхука (шаги 2a-2b в одной транзакции):
a. Обновляетnext_run_atна следующее запланированное время
b. Создает delivery запись (status=pending) - После коммита транзакции — enqueue'ит goqite job
deliver_cron_webhook(delivery_id)
Атомарное обновление next_run_at + создание delivery в одной транзакции предотвращает дублирование триггеров при краше процесса между enqueue и обновлением next_run_at.
robfig/cron — только как парсер
robfig/cron/v3 используется только как парсер (cron.ParseStandard) для вычисления следующего времени запуска из cron expression. Он не используется как scheduler — расписание хранится в БД в поле next_run_at.
parser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
schedule, err := parser.Parse(webhook.CronSchedule)
nextRun := schedule.Next(time.Now())
Timezone
Timezone берётся из конфигурации проекта (config.timezone). Cron expressions интерпретируются в этой timezone.
Поведение при перезапуске сервера
Пропущенные выполнения НЕ восстанавливаются — это стандартное поведение cron. При рестарте system cron просто проверяет next_run_at <= now() и запускает то, что просрочено. Если сервер был выключен несколько часов — каждый webhook выполнится один раз (не N раз за пропущенные интервалы).
Регистрация cron job
В cmd/server/cronjobs.go:
func getCronJobConfigs(app *app) []cronjobs.Job {
jobs := []cronjobs.Job{
// ... existing jobs
&executecronwebhooks.Job{},
}
return jobs
}
goqite worker
Background job internal/case/backjob/delivercronwebhook/:
- Загрузить delivery + webhook из БД
- Если
pass_api_key=true-> создать shortapitoken JWT (TTL из appconfig, depth=current+1, read_patterns, write_patterns) - Подписать payload HMAC-SHA256
- POST url с payload + headers (timeout = webhook.timeout_seconds)
- Если response содержит
changes-> импортировать через InsertNote - Сохранить результат (status, response, duration_ms)
Защита от рекурсии (depth)
Depth tracking через shortapitoken JWT + max_depth. Подробности механизма — см. shared_webhooks.md.
Default max_depth=1 для cron webhooks:
- depth0 (прямые правки) -> триггерит change webhooks с max_depth > 1
- depth1 (правки от cron webhook агента) -> НЕ триггерит change webhooks с max_depth1
Чтобы change webhook срабатывал на правки от cron webhook агента — установить max_depth=2.
Интеграция
Точки интеграции
| Компонент | Файл | Что делает |
|---|---|---|
| Cron job | internal/case/cronjob/executecronwebhooks/ |
Проверяет next_run_at, создает deliveries, enqueue |
| Background job | internal/case/backjob/delivercronwebhook/ |
HTTP POST + parse response + import changes |
| Admin mutations | internal/case/admin/createcronwebhook/, updatecronwebhook/, deletecronwebhook/ |
CRUD операции |
| Admin queries | internal/graph/schema.resolvers.go |
cronWebhooks, cronWebhookDeliveries |
| Shared | internal/shortapitoken/, HMAC, agent response, retry |
См. shared_webhooks.md |
GraphQL схема
input CreateCronWebhookInput {
url: String!
cronSchedule: String!
instruction: String! = ""
secret: String # автогенерируется если не задан
passApiKey: Boolean! = false
timeoutSeconds: Int! = 60
maxDepth: Int! = 1
maxRetries: Int! = 0
enabled: Boolean! = true
description: String! = ""
readPatterns: [String!]! = ["*"]
writePatterns: [String!]! = []
}
input UpdateCronWebhookInput {
id: Int!
url: String
cronSchedule: String
instruction: String
secret: String
passApiKey: Boolean
timeoutSeconds: Int
maxDepth: Int
maxRetries: Int
enabled: Boolean
description: String
readPatterns: [String!]
writePatterns: [String!]
}
type CronWebhook {
id: Int!
url: String!
cronSchedule: String!
instruction: String!
hasSecret: Boolean! # не раскрывать сам secret
passApiKey: Boolean!
timeoutSeconds: Int!
maxDepth: Int!
maxRetries: Int!
enabled: Boolean!
description: String!
readPatterns: [String!]!
writePatterns: [String!]!
nextRunAt: DateTime
createdAt: DateTime!
lastDeliveryAt: DateTime # удобно для UI
lastDeliveryStatus: String # success/failed
}
type CronWebhookDelivery {
id: Int!
cronWebhookId: Int!
status: String!
responseStatus: Int
attempt: Int!
durationMs: Int
createdAt: DateTime!
completedAt: DateTime
}
type TriggerCronWebhookPayload {
deliveryId: Int!
}
type Query {
cronWebhooks: [CronWebhook!]!
cronWebhookDeliveries(cronWebhookId: Int!, limit: Int = 50): [CronWebhookDelivery!]!
}
type Mutation {
createCronWebhook(input: CreateCronWebhookInput!): CreateCronWebhookPayload!
updateCronWebhook(input: UpdateCronWebhookInput!): UpdateCronWebhookPayload!
deleteCronWebhook(id: Int!): DeleteCronWebhookPayload!
regenerateCronWebhookSecret(id: Int!): RegenerateSecretPayload!
triggerCronWebhook(webhookId: Int!): TriggerCronWebhookPayload!
}
Структура кода
Новые пакеты
internal/case/admin/
├── createcronwebhook/
│ ├── resolve.go — создание cron webhook (admin mutation)
│ └── resolve_test.go
├── updatecronwebhook/
│ ├── resolve.go — обновление url/schedule/instruction/etc
│ └── resolve_test.go
├── deletecronwebhook/
│ ├── resolve.go — soft delete (disabled_at)
│ └── resolve_test.go
└── listcronwebhookdeliveries/
└── resolve.go — история доставок
internal/case/cronjob/executecronwebhooks/
├── job.go — cron job definition (Schedule, Execute)
├── resolve.go — select due webhooks by next_run_at, create deliveries, enqueue, update next_run_at
└── resolve_test.go
internal/case/backjob/delivercronwebhook/
├── job.go — JobID, QueueID, Priority
├── resolve.go — HTTP POST, shortapitoken, HMAC, parse response, import changes
└── resolve_test.go
internal/shortapitoken/
├── token.go — JWT sign/parse с depth + read_patterns + write_patterns в claims
└── token_test.go
cmd/server/case_methods.go
└── func (a *app) ImportNotesFromChanges(ctx, changes, depth)
SQL-запросы (sqlc)
-- queries.read.sql
-- name: ListCronWebhooks :many
select * from cron_webhooks where disabled_at is null order by created_at;
-- name: ListEnabledCronWebhooks :many
select * from cron_webhooks where enabled = true and disabled_at is null;
-- name: CronWebhookByID :one
select * from cron_webhooks where id = ? and disabled_at is null;
-- name: ListCronWebhookDeliveries :many
select * from cron_webhook_deliveries
where cron_webhook_id = ?
order by created_at desc
limit ?;
-- name: ListCronWebhooksDueForExecution :many
select * from cron_webhooks
where enabled = true
and disabled_at is null
and next_run_at <= datetime('now');
-- queries.write.sql
-- name: InsertCronWebhook :one
insert into cron_webhooks (url, cron_schedule, instruction, secret, pass_api_key, timeout_seconds, max_depth, max_retries, next_run_at, read_patterns, write_patterns, description, created_by)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
returning *;
-- name: UpdateCronWebhook :one
update cron_webhooks
set url = coalesce(?, url),
cron_schedule = coalesce(?, cron_schedule),
instruction = coalesce(?, instruction),
secret = coalesce(?, secret),
pass_api_key = coalesce(?, pass_api_key),
timeout_seconds = coalesce(?, timeout_seconds),
max_depth = coalesce(?, max_depth),
max_retries = coalesce(?, max_retries),
read_patterns = coalesce(?, read_patterns),
write_patterns = coalesce(?, write_patterns),
enabled = coalesce(?, enabled),
description = coalesce(?, description),
updated_at = datetime('now')
where id = ? and disabled_at is null
returning *;
-- name: UpdateCronWebhookNextRunAt :exec
update cron_webhooks
set next_run_at = ?, updated_at = datetime('now')
where id = ?;
-- name: RegenerateCronWebhookSecret :one
update cron_webhooks
set secret = ?, updated_at = datetime('now')
where id = ? and disabled_at is null
returning *;
-- name: DisableCronWebhook :exec
update cron_webhooks
set disabled_at = datetime('now'), disabled_by = ?, enabled = false
where id = ?;
-- name: InsertCronWebhookDelivery :one
insert into cron_webhook_deliveries (cron_webhook_id, attempt)
values (?, ?)
returning *;
-- name: UpdateCronWebhookDeliveryResult :exec
update cron_webhook_deliveries
set status = ?, response_status = ?, duration_ms = ?,
completed_at = datetime('now')
where id = ?;
План реализации
Этап 1: Инфраструктура
- Миграция: таблицы
cron_webhooks(с next_run_at, read_patterns, write_patterns) +cron_webhook_deliveries - SQL-запросы (sqlc): включая
ListCronWebhooksDueForExecution,UpdateCronWebhookNextRunAt,RegenerateCronWebhookSecret+make sqlc internal/shortapitoken/— JWT sign/parse с depth + read_patterns + write_patterns (если не существует)
Этап 2: MVP (ядро)
- Admin mutations: create/update/delete cron webhook +
regenerateCronWebhookSecret+triggerCronWebhook - Cron job:
internal/case/cronjob/executecronwebhooks/— select bynext_run_at <= now(), create deliveries, update next_run_at (парсинг cron_schedule черезcron.ParseStandard) - Background job:
internal/case/backjob/delivercronwebhook/— HTTP POST + HMAC + shortapitoken (с read/write patterns) + parse response - Import changes:
ImportNotesFromChangesметод — парсинг changes из ответа агента, вызов InsertNote - Расширить auth:
checkapikey— поддержкаAuthorization: Bearerдля shortapitoken (если еще не сделано) - Регистрация cron job в
cmd/server/cronjobs.go— jobexecutecronwebhooksкаждую минуту - Admin queries:
cronWebhooks,cronWebhookDeliveries - job_statuses: интеграция с таблицей job_statuses для отслеживания delivery
- Timezone: cron_schedule парсится с timezone из
config.timezone - Debug endpoints (
DEV_MODE=true): общие с change_webhooks — см. shared_webhooks.md
Этап 3: UI
- Фронтенд: CRUD cron webhooks в админке
- Фронтенд: просмотр истории deliveries
- Кнопка "Run now" для ручного запуска (
triggerCronWebhook)
Этап 4: Улучшения (опционально)
- Метрика: success rate за последние 24ч/7д
- Автоотключение cron webhook после N последовательных failures
- Include context notes в payload — опциональная фича
- Alerting: уведомление в Telegram/email при N последовательных failures
Решённые вопросы
-
Response schema валидация? Нет runtime JSON Schema валидации.
response_schema— серверная константа (не хранится в БД, не задаётся админом), включается в payload чтобы агент знал формат ответа. Сервер валидирует agent response через ozzo-validation на Go struct (обязательные поля: path, content). -
Sync vs Async? Оба режима поддержаны. Агент решает сам: вернуть changes в ответе или 202 Accepted и работать через API.
-
Timeout? Конфигурируемый
timeout_seconds(default 60s). Можно увеличить для тяжелых задач или использовать async режим. -
Рекурсия? depth tracking через shortapitoken JWT + max_depth в cron_webhooks. Default max_depth1 (правки от агента НЕ триггерят change webhooks с max_depth1).
-
Secret обязательный? Да, автогенерируется при создании. Payload всегда подписан HMAC-SHA256.
-
Cron scheduling? System cron каждую минуту +
next_run_atв БД.robfig/cron/v3используется только как парсер (cron.ParseStandard) для вычисления следующего времени запуска. Не используется как scheduler. -
Token scope / write path restrictions? Решено через
read_patternsиwrite_patterns. Передаются в shortapitoken JWT. read_patterns default["*"], write_patterns default[](безопасный default). -
Timezone? Берётся из конфигурации проекта (
config.timezone). -
Retry? Unified
max_retriesна уровне webhook. goqite MaxReceive не используется для retry. Подробности — см. shared_webhooks.md.
Открытые вопросы / Future
Execution Group (future)
Группа последовательного выполнения. Колонка execution_group text not null default ''. Все вебхуки с одинаковым execution_group выполняются последовательно. Отложено на будущее.
Include context notes
Как в change webhooks include_patterns — отправлять в payload контекстные заметки:
{
"instruction": "...",
"context_notes": [
{"path": "prompts/digest.md", "content": "..."}
]
}
Решение: future feature. Для MVP инструкция может содержать пути к нужным заметкам.