Для AI-агентов: markdown этой страницы — /docs-content/infra/jobs/daily-digest.md индекс документации — /llms.txt

Ежедневный дайджест

Цель: каждый день в 09:00 Europe/Moscow принять запрос на /tasks/digest?mode=daily и поставить сборку дайджеста в устойчивую очередь приложения. Создайте задачу с настройками ниже и сохраните секрет из ответа в защищённой конфигурации обработчика. Это пример настройки, не подтверждение доступа вашего портала.

JSON
{
  "cronExpr": "0 9 * * *",
  "timezone": "Europe/Moscow",
  "path": "/tasks/digest?mode=daily",
  "label": "daily digest",
  "enabled": true
}

Обработчик

Разместите показанные на странице подписи verifier и HTTP-reader в verifier.ts и http-adapter.ts. Создайте таблицы accepted_runs/job_outbox из той же страницы. Для примера нужен пакет pg. Задайте EXPECTED_JOB_ID, JOB_SIGNING_SECRET и DATABASE_URL на сервере; не отправляйте их браузеру. Endpoint должен быть доступен через доверенный платформенный вход.

TypeScript
import { createServer } from 'node:http';
import { Pool } from 'pg';
import { verifyJobRequest } from './verifier.js';
import { readRawBody, singleHeader } from './http-adapter.js';

const secret = process.env.JOB_SIGNING_SECRET;
const expectedJobId = process.env.EXPECTED_JOB_ID;
if (!secret || !expectedJobId) throw new Error('Missing handler configuration');
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
createServer(async (req, res) => {
  if (req.method !== 'POST' || req.url !== '/tasks/digest?mode=daily') {
    res.writeHead(404).end(); return;
  }
  try {
    const rawBody = await readRawBody(req);
    if (!verifyJobRequest({ callerKind: singleHeader(req, 'X-Vibe-Caller-Kind'),
      singleSignatureHeader: singleHeader(req, 'Vibe-Signature'), rawBody,
      secret, nowSeconds: Math.floor(Date.now() / 1000) })) {
      res.writeHead(401).end(); return;
    }
    let p;
    try { p = JSON.parse(rawBody.toString('utf8')); }
    catch { res.writeHead(400).end(); return; }
    if (!p || p.type !== 'scheduled_job.run' || p.jobId !== expectedJobId ||
      typeof p.runId !== 'string' || p.runId.length < 1 || p.runId.length > 200 ||
      typeof p.scheduledFor !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(p.scheduledFor) ||
      !Number.isFinite(Date.parse(p.scheduledFor)) || !Number.isInteger(p.attempt) ||
      p.attempt < 1 || p.attempt > 4 || typeof p.late !== 'boolean' ||
      !['schedule', 'manual'].includes(p.trigger)) {
      res.writeHead(400).end(); return;
    }
    const db = await pool.connect();
    try {
      await db.query('BEGIN');
      await db.query(`WITH accepted AS (
        INSERT INTO accepted_runs(run_id,response_status,response_body)
        VALUES ($1,202,'{"accepted":true}'::jsonb)
        ON CONFLICT (run_id) DO NOTHING RETURNING run_id
      ) INSERT INTO job_outbox(run_id,payload)
        SELECT run_id,$2::jsonb FROM accepted`, [p.runId, JSON.stringify(p)]);
      const saved = await db.query(
        'SELECT response_status,response_body FROM accepted_runs WHERE run_id=$1', [p.runId]);
      if (saved.rows.length !== 1) throw new Error('Missing acceptance receipt');
      await db.query('COMMIT');
      res.writeHead(saved.rows[0].response_status, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify(saved.rows[0].response_body));
    } catch {
      await db.query('ROLLBACK');
      res.writeHead(503).end();
    } finally { db.release(); }
  } catch (error) {
    res.writeHead(error instanceof Error && error.message === 'BODY_TOO_LARGE' ? 413 : 503).end();
  }
}).listen(3000);

Работа после HTTP-ответа

202 означает, что runId и работа записаны в одной транзакции. Повтор этого runId возвращает сохранённый 202, не добавляя новую работу. Worker приложения обрабатывает job_outbox отдельно, с устойчивым захватом и восстановлением после сбоя; для отправки дайджеста внешнему сервису используйте runId как idempotency key. Отмечайте processed_at в outbox и квитанции после окончания. Квитанции храните не меньше настроенного срока журнала (по умолчанию 90 дней, минимум 7), незавершённые работы не удаляйте.

Журнал запуска покажет SUCCEEDED после HTTP 202, даже если сборка ещё идёт. Показывайте окончание в собственном статусе приложения или уведомляйте пользователя из worker. Обычный idle timer сервера не гарантирует защиту до окончания фоновой работы: выбирайте режим работы и длительность задач с учётом этого. Ручной запуск подходит для проверки, но каждый POST создаёт отдельную работу.