No description
  • Go 96%
  • HTML 3.5%
  • Dockerfile 0.5%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Yaroslav Glukhov 5105cd929d
All checks were successful
Go checks / test (push) Successful in 6m17s
feat(cli): embed build version and expose version commands
2026-09-10 05:08:35 -05:00
.forgejo/workflows ci: build snapshot artifacts and tagged releases on Forgejo 2026-09-10 05:07:53 -05:00
cmd/jobwire feat(cli): embed build version and expose version commands 2026-09-10 05:08:35 -05:00
deploy feat(storage): use platform data directories with database overrides 2026-09-10 05:07:24 -05:00
docs/specs Document proposed federation protocol in README 2026-09-07 13:57:32 -05:00
internal feat(storage): use platform data directories with database overrides 2026-09-10 05:07:24 -05:00
migrations Refactor application components and update related workflows 2026-09-07 13:07:08 -05:00
scripts feat(cli): embed build version and expose version commands 2026-09-10 05:08:35 -05:00
.dockerignore feat(deploy): add container image and Compose services 2026-09-10 05:08:19 -05:00
.gitattributes ci: build snapshot artifacts and tagged releases on Forgejo 2026-09-10 05:07:53 -05:00
.gitignore feat(deploy): add container image and Compose services 2026-09-10 05:08:19 -05:00
compose.yaml feat(deploy): add container image and Compose services 2026-09-10 05:08:19 -05:00
config.container.example.yaml feat(deploy): add container image and Compose services 2026-09-10 05:08:19 -05:00
config.example.yaml feat(storage): use platform data directories with database overrides 2026-09-10 05:07:24 -05:00
Dockerfile feat(cli): embed build version and expose version commands 2026-09-10 05:08:35 -05:00
go.mod init repo 2026-09-07 10:16:35 -05:00
go.sum init repo 2026-09-07 10:16:35 -05:00
README.md feat(cli): embed build version and expose version commands 2026-09-10 05:08:35 -05:00

jobwire — Job Aggregator MVP

Self-hosted агрегатор вакансий на Go + SQLite: discover now → use now → enrich later.

Источники: Amazon / AWS (amazon.jobs), Greenhouse, Lever и Workday.

jobwire sync получает дешёвые метаданные и публикует их. jobwire enrich независимо обрабатывает постоянную очередь описаний. jobwire serve предоставляет JSON API, server-rendered HTML и RSS. Discovery никогда не вызывает массовую загрузку деталей.

Запуск

Нужен Go 1.25+. SQLite использует pure-Go драйвер modernc.org/sqlite, без CGO или отдельного сервера БД.

Локальные бинарники собираются в bin/, релизные архивы — в dist/. Обе директории исключены из Git. Всегда указывайте -o при сборке: go build ./cmd/jobwire без этого параметра создаст бинарник в корне проекта.

go build -o bin/jobwire.exe ./cmd/jobwire
Copy-Item config.example.yaml config.yaml
# Добавьте targets в config.yaml.
.\bin\jobwire.exe sync
.\bin\jobwire.exe serve
# В другом терминале:
.\bin\jobwire.exe enrich

Linux:

go build -o bin/jobwire ./cmd/jobwire
cp config.example.yaml config.yaml
./bin/jobwire sync
./bin/jobwire serve
# В другом терминале:
./bin/jobwire enrich

Шаблоны и миграции встроены в один бинарник. БД и её родительская директория создаются автоматически. Явно заданные относительные пути отсчитываются от рабочей директории процесса, а не файла config.

Web UI, API, RSS.

Версия

jobwire --version и jobwire version выводят встроенную в бинарник версию, например jobwire v0.1.0. Команда не требует конфигурации, Git или доступа к базе данных. Обычная локальная сборка показывает dev, CI-сборка — snapshot, релизная — git-тег. scripts/release.go передаёт версию через Go linker flag -X main.version=....

Для сборки контейнера с выбранной версией:

docker build --build-arg VERSION=v0.1.0 -t jobwire:v0.1.0 .
docker run --rm jobwire:v0.1.0 --version

Без VERSION контейнерная сборка также показывает dev.

Расположение базы данных

Платформа База по умолчанию
Linux / другие Unix $XDG_DATA_HOME/jobwire/jobwire.db или ~/.local/share/jobwire/jobwire.db
macOS ~/Library/Application Support/jobwire/jobwire.db
Windows %LOCALAPPDATA%\jobwire\jobwire.db

Относительный XDG_DATA_HOME игнорируется. На Windows при отсутствии LOCALAPPDATA используется ~/AppData/Local/jobwire/jobwire.db. На macOS XDG_DATA_HOME не используется.

Путь можно переопределить через JOBWIRE_DB или глобальный флаг --db:

jobwire --db ./sample.db sync
jobwire --db ./sample.db serve

Приоритет: --db → JOBWIRE_DB → поле database в YAML → системный путь. Поле YAML сохранено для совместимости; если оно отсутствует или пустое, используется системный путь (при отсутствии флага и environment override). Флаг --db также принимается после команды. Все процессы serve/sync/enrich должны использовать один и тот же путь БД.

Старая ./jobs.db автоматически не импортируется. При обновлении существующей установки остановите все её процессы, сделайте резервную копию, выполните SQLite PRAGMA wal_checkpoint(TRUNCATE) и убедитесь, что checkpoint завершился без busy. Закройте SQLite перед переносом базы. Перенесите jobs.db в новый каталог под именем jobwire.db, затем удалите старое database: ./jobs.db из конфига. Не отбрасывайте WAL без успешного checkpoint: он может содержать ещё не записанные в основной файл данные. В качестве альтернативы сохраните существующую базу на месте и задайте её путь явно.

Команды

Команда Поведение
jobwire sync Один discovery всех настроенных targets; без Workday detail requests
jobwire enrich Постоянные workers, ожидание новых и отложенных задач
jobwire enrich -once Обработать доступные сейчас задачи в пределах бюджета и выйти
jobwire serve HTTP API, HTML и RSS
jobwire providers Названия поддерживаемых провайдеров

Для каждой команды доступен -config path.yaml; флаги задаются после команды. enrich -once не ждёт будущего next_attempt_at и не опустошает очередь сверх бюджета. Exit code 1 означает ошибки обработанных попыток, остальные задачи при этом продолжают обрабатываться. В режиме service отдельная ошибка вакансии логируется и не останавливает workers; ошибка самого хранилища завершает процесс. Ctrl+C отменяет сетевые запросы и возвращает незавершённые задачи в очередь. После аварийного завершения задачи становятся доступны по истечении двухминутной аренды.

Конфигурация

Сохранена совместимость с прежней формой sources: provider: [targets]. Настройки ограничений вынесены в providers.

# database: /custom/path/jobwire.db # optional; otherwise use the platform default

server:
  listen: 127.0.0.1:8080
  base_url: https://jobs.yarikus.com

enrichment:
  workers: 8
  refresh_after: 72h
  max_attempts: 5

providers:
  workday:
    concurrency: 2
    requests_per_second: 2
    max_detail_requests_per_run: 200
    budget_window: 1h
  greenhouse:
    concurrency: 8
  lever:
    concurrency: 8
  amazon:
    requests_per_second: 2

sources:
  amazon:
    - name: amazon-austin-tx
      company: Amazon / AWS
      max_pages: 20
      amazon:
        normalized_country_code: [USA]
        normalized_state_name: [Texas]
        normalized_city_name: [Austin]
        # base_query: <нужные ключевые слова>
  greenhouse:
    - name: your-board-token
      company: Example Company
  lever:
    - name: your-site-name
      company: Another Company
      region: global # либо eu
  workday:
    - name: nvidia-external
      company: NVIDIA
      url: https://nvidia.wd5.myworkdayjobs.com
      tenant: nvidia
      site: NVIDIAExternalCareerSite

В config.example.yaml targets пустые: добавьте нужные boards явно. Для Greenhouse/Lever доступна короткая форма greenhouse: [board-a, board-b]; без company отображается slug.

  • Greenhouse: name — board token.
  • Lever: name — site name; регион по умолчанию global.
  • Workday: обязательны стабильное имя target, HTTPS origin *.myworkdayjobs.com, tenant и site. Workday data-center hostname нельзя надёжно вывести из названия компании.
  • Amazon: name — стабильное имя отфильтрованного поиска; параметры поиска задаются в блоке amazon, подробнее ниже.
  • base_url — origin для RSS channel link; не берётся из HTTP Host.
  • По умолчанию: 8 общих workers, 72h TTL, 5 попыток. Workday и Amazon: concurrency 2 и 2 запроса/сек; остальные providers: concurrency 8 и 4 запроса/сек. Amazon выполняет listing последовательно и не использует detail workers.
  • Бюджет по умолчанию — 200 detail requests на provider за запуск -once. В постоянном процессе бюджет возобновляется раз в budget_window, по умолчанию 1h.
  • Бюджет учитывает попытки, включая ошибочные запросы. Он локален процессу и сбрасывается после рестарта. Запускайте один enrich service; несколько процессов умножат бюджеты и лимиты конкурентности.
  • Нулевые значения provider limits означают defaults, а не unlimited. enrichment.workers, refresh_after и max_attempts должны быть положительными.
  • Конфигурация читается при запуске; после изменения sources или limits перезапустите соответствующие процессы.

Публичные endpoints не требуют secrets. Текущая YAML-схема не поддерживает secrets и environment interpolation; для будущих авторизованных providers secrets должны поступать через environment.

Amazon / AWS

Provider использует публичный amazon.jobs search JSON endpoint. Он обслуживает глобальную доску Amazon, поэтому каждый target должен сужать поиск через base_query или facet array: normalized_country_code, normalized_state_name, normalized_city_name, category либо business_category.

В примерах Amazon настроен на Austin, Texas, США: amazon-austin-tx. Страна, штат и город заданы отдельными facet arrays. Ограничение по специальности не задано.

Вложенный блок amazon передаёт scalar values как обычные query parameters, массивы — как повторяющиеся key[]=value. Например, normalized_country_code: [USA, CAN] становится normalized_country_code[]=USA&normalized_country_code[]=CAN. Ключ с уже указанными [] тоже поддерживается. Объекты и вложенные массивы не допускаются; null пропускается.

loc_query сам по себе ненадёжен: endpoint может проигнорировать текстовое ограничение страны. Его можно передать дополнительно, но он не считается достаточным ограничением target. Для страны используйте массив трёхбуквенных кодов, например normalized_country_code: [USA].

sort по умолчанию recent. Provider использует обычную пагинацию: result_limit=100 и offset. Эти параметры задаются самим provider; переопределять их в конфигурации нельзя. max_pages ограничивает весь target: по умолчанию 20, максимум 100. Автоматического разбиения поиска на дополнительные запросы по категориям или локациям нет.

Если результат превышает max_pages или hits достигает обрезанного API значения 10 000, нужно сузить target. Повторяющиеся postings, изменение counts и неполные страницы завершают target с ошибкой: частичный snapshot не применяется и старые вакансии не деактивируются.

При 429 сохраняется HTTP error с Retry-After, текущий target завершается без немедленного retry; повтор выполняется следующим запуском sync.

source_id и requisition_id — числовой posting ID из job_path, сверяемый с id_icims, если он присутствует. company_name предпочтительнее fallback company из конфигурации. Сохраняются canonical URL, location, posted date, schedule и исходный JSON.

Listing уже может содержать полные description, basic_qualifications и preferred_qualifications. Они сразу сохраняются как plain text с detail_status=complete без дополнительных HTTP requests. Если полного description нет, вакансия публикуется с description=null и detail_status=not_fetched; отдельная задача enrichment для Amazon не создаётся. description_short не выдаётся за полный текст. Данные обновятся при следующем успешном discovery.

Разные имена Amazon targets создают разные области identity. При пересекающихся фильтрах одна Amazon-вакансия может появиться в нескольких targets; cross-target дедупликация не выполняется. После изменения фильтра исчезновение означает выход из результата поиска этого target, а не обязательно закрытие на всей доске Amazon.

Discovery и identity

Один target сначала полностью получает listing, затем одной SQLite-транзакцией:

  1. создаёт и обновляет metadata;
  2. сохраняет первоначальный id и discovered_at;
  3. ставит новые detail-задачи в очередь;
  4. помечает исчезнувшие записи inactive.

После этой транзакции новые вакансии доступны API, HTML и RSS, даже если description = null. Discovery не ждёт workers.

Unique key — (source, target, source_id). Одинаковые IDs в разных targets создают независимые записи. target — настроенное name; переименование target меняет область identity, поэтому выбирайте имя надолго.

Workday source_id берётся из posting identifier в конце externalPath, включая суффиксы наподобие JR123-1. requisition_id хранится отдельно из listing, когда доступен. Это позволяет сохранять разные публикации одной requisition. Tenant больше не включается в source_id: namespace задаётся target.

Исчезнувшие вакансии не удаляются. Ошибка listing не применяет частичный снимок и не деактивирует старые вакансии. Успешный пустой listing может деактивировать весь target. Ошибка одного target не останавливает остальные; sync завершится с кодом 1, если были ошибки.

Удаление target из YAML не деактивирует его вакансии: без успешного listing нельзя утверждать, что они исчезли. Их enrichment прекращается, поскольку workers выбирают только настроенные targets.

updated_at — время изменения агрегатором. Реактивация сохраняет ID и дату первого обнаружения. Снимок более старого перекрывающегося sync одного target отклоняется.

Enrichment и очередь

enrichment_tasks в SQLite содержит job_id, числовой priority, status, attempts, next_attempt_at, даты создания/обновления, last_error и поля аренды.

  • Новые вакансии имеют normal priority 0; TTL refresh — low priority -10.
  • Очередь сортируется по priority DESC, затем сроку выполнения и времени создания.
  • Store.Prioritize позволяет повысить приоритет для будущего on-demand API. Сам endpoint пока не реализован.
  • Задача захватывается атомарно с уникальным lease token на 2 минуты. Поздний ответ старого worker не может затереть результат новой попытки.
  • Один detail HTTP request имеет таймаут 45 секунд. Ответ ограничен 32 MiB.
  • 429, 502, 503, 504, временные сетевые ошибки, timeout и connection reset переносятся на next_attempt_at.
  • Exponential backoff: база 30 секунд для 429, 5 секунд для остальных временных ошибок, рост до 1h плюс jitter. Retry-After задаёт минимальную паузу и может быть больше 1h.
  • При 429 provider cooldown сохраняется в БД; пока он действует, новые задачи этого provider не захватываются. Уже выполняющиеся запросы могут завершиться.
  • Invalid ref, invalid payload и HTTP 404 завершают задачу с постоянной ошибкой. После max_attempts временная ошибка также становится постоянной; discovery не сбрасывает failed-задачи при каждом запуске.
  • Неполное enrichment не скрывает вакансию и не меняет active/inactive.
  • Исчезнувшие вакансии отменяют pending/fetching задачи. Смена detail reference аннулирует старую попытку и ставит новую.
  • TTL проверяется при sync, при старте enrich и раз в минуту в постоянном enrich. Старое описание остаётся доступным во время refresh.
detail_status Значение
not_fetched Детали отсутствуют и ещё не поставлены в очередь
pending Первичная загрузка поставлена в очередь
fetching Worker захватил задачу
complete Детали доступны
error Последняя попытка не удалась; повтор может быть запланирован
stale Старые детали доступны, refresh ожидает выполнения

В таблице задач используются pending, fetching, done, failed, canceled. detail_attempted_at записывается при захвате задачи, detail_fetched_at — после успешного получения данных. Сохранение результата, состояние задачи и изменение detail_status выполняются атомарно.

Providers

  • Greenhouse Job Board API: content=true уже возвращает описание в listing. Эти дешёвые данные сохраняются сразу, отдельная detail-очередь не нужна.
  • Lever Postings API: listing содержит description, lists и дополнительные части текста. Provider объединяет их без дополнительных detail-запросов.
  • Workday CXS: POST /wday/cxs/{tenant}/{site}/jobs возвращает metadata; GET /wday/cxs/{tenant}/{site}{externalPath} вызывается только enrichment worker.

Workday иногда возвращает total: 0 на последующих непустых страницах и ограничивает выдачу 2000 записями; запросы за этим пределом могут повторять первую страницу. При capped total provider разделяет listing по flat facets из ответа самого API, рекурсивно уточняет слишком большие части и объединяет результаты с удалением точных дублей между категориями. Если безопасное разбиение недоступно, discovery завершается ошибкой вместо публикации обрезанного списка. Повторяющиеся IDs внутри страницы/раздела, конфликты ID, изменившийся положительный total и неполная выдача вызывают ошибку всего listing. Пределы: 100 000 вакансий и 256 разделов.

CXS — API публичного карьерного сайта без гарантированного стабильного контракта. Отдельные tenants могут отличаться. Ошибки listing откладываются до следующего запуска sync; persistent retries применяются к enrichment.

Descriptions преобразуются в plain text; относительные даты вроде Posted Today не выдумываются в точные timestamps. Неизвестные posted_at остаются null, employment_type — unknown. Исходный listing хранится в jobs.raw, отдельный detail — в jobs.raw_detail. Они не публикуются в API/UI/RSS.

HTTP, HTML и RSS

Путь Назначение
GET / 303 redirect на /jobs
GET /jobs HTML список и фильтры, включая отображение detail_status
GET /jobs/{id} Metadata, detail_status, описание либо Details are not loaded yet, ссылка Open original
GET /api/health Проверка БД, ответ {"status":"ok"}
GET /api/jobs Metadata без description/raw
GET /api/jobs/{id} Нормализованная вакансия, nullable description и detail timestamps
GET /feed.xml Последние 50 active вакансий, включая ещё не enriched

Общие фильтры HTML и API:

Параметр Семантика
q Подстрока в title, company, location или description
company, location Подстрока соответствующего поля
source Точное имя провайдера без учёта регистра
status active по умолчанию, inactive или all
limit 50 по умолчанию, максимум 200; 0 означает default
offset 0 по умолчанию

Фильтры соединяются через AND. Поиск подстроки нечувствителен к регистру Unicode, без stemming/нормализации Unicode; % и _ — обычные символы. Отсутствие description не мешает совпадениям metadata. Порядок — discovered_at DESC, id DESC. Offset pagination может сдвигаться между sync. Ошибочные параметры — 400, неизвестный ID — 404.

{
  "id": "local-stable-id",
  "source": "workday",
  "source_id": "JR123",
  "target": "nvidia-external",
  "company": "NVIDIA",
  "title": "Engineer",
  "description": null,
  "detail_status": "pending",
  "detail_fetched_at": null
}

Пример выше — фрагмент detail response. Полный ответ также содержит location, url, employment_type, requisition_id, status, posted_at, discovered_at, updated_at и detail_attempted_at. Даты — RFC3339 UTC.

Список имеет форму {"jobs":[...],"limit":50,"offset":0}; пустая выдача — jobs: [].

RSS 2.0 использует тот же search/storage layer: title — title — company, description — company/location, link — оригинальная вакансия, pubDate — discovered_at, GUID — локальный ID с isPermaLink="false". Enrichment не создаёт новый RSS item и не меняет discovery order. Query parameters RSS пока игнорируются.

Миграция существующей установки

Перед обновлением остановите все старые serve/sync/enrich процессы, сделайте backup и замените бинарник. Новые процессы автоматически применяют 002_enrichment.sql; старый бинарник с новой схемой несовместим.

Миграция сохраняет существующие описания и timestamps, выделяет target-local identities и сохраняет исходный локальный ID для первого target. Если старую запись разделяли несколько targets, для остальных создаются новые IDs. Старый Workday префикс tenant: снимается.

У исторических inactive записей старая схема могла уже потерять target membership. При единственном известном target данного source используется он; при неоднозначности запись сохраняется с target = legacy-unknown. Данные не удаляются, но такие записи требуют ручного сопоставления, если их нужно вернуть в discovery. Старые requisition IDs и новые posting IDs с суффиксом могут представлять разные публикации и не объединяются fuzzy matching.

Схема версионируется через schema_migrations. Не редактируйте применённые SQL; добавляйте следующую миграцию. Неизвестная версия БД отклоняется.

Релизы в Forgejo CI

Workflows находятся в .forgejo/workflows/: ci.yml проверяет push веток и pull requests, release.yml проверяет код и публикует релиз при push тега v*. После успешных проверок обычный CI загружает артефакт jobwire-<commit SHA>: архивы версии snapshot для Linux amd64/arm64 и Windows amd64 плюс SHA256SUMS. Их можно скачать в разделе Artifacts на странице запуска Actions без создания релиза. Срок хранения — 14 дней (в пределах политики сервера). Tar сохраняет право исполнения бинарника. Используется совместимый с Forgejo upload-artifact@v3, согласно документации артефактов. Поддерживаются версии вида v0.1.0 и v0.1.0-rc.1; версии с суффиксом публикуются как prerelease.

Один раз включите Actions в настройках репозитория Forgejo и подключите Linux runner с меткой docker. Оба workflow явно запускаются в node:22-bookworm. Go устанавливается action по версии из go.mod. Сборку бинарников, tar.gz/zip и checksums выполняет go run ./scripts/release.go "$RELEASE_VERSION" стандартной библиотекой Go; отдельная установка утилит упаковки не нужна. Для action публикации релиза предполагается, что jq уже доступен в окружении выполнения. Runner должен иметь доступ к Docker Hub, Forgejo, github.com, code.forgejo.org и загрузкам Go/modules. Используется встроенный token задания Forgejo; отдельный PAT обычно не нужен. Настройка runner описана в Forgejo Actions quick start.

После отправки workflow и кода в репозиторий новый релиз запускается двумя командами (выберите ещё не использованную версию):

git tag v0.1.0
git push origin v0.1.0

В разделе Releases появятся:

jobwire_v0.1.0_linux_amd64.tar.gz
jobwire_v0.1.0_linux_arm64.tar.gz
jobwire_v0.1.0_windows_amd64.zip
SHA256SUMS

Архив содержит бинарник jobwire (jobwire.exe для Windows), README и пример конфига. Linux-архивы также содержат systemd units в deploy/. Локальные config.yaml и базы не упаковываются. Ubuntu Server на Intel/AMD использует linux_amd64, на ARM — linux_arm64. После скачивания архива и SHA256SUMS в одну папку проверьте checksum и распакуйте:

sha256sum --ignore-missing -c SHA256SUMS
mkdir -p dist/unpacked
tar -xzf jobwire_v0.1.0_linux_amd64.tar.gz -C dist/unpacked

CI выполняет gofmt check, go test ./... и go vet ./... перед сборкой. Релиз публикуется после загрузки всех файлов; существующий релиз автоматически не перезаписывается. При ошибке загрузки может остаться draft: удалите незавершённый draft перед повторным запуском задания. Сборка сама по себе не обновляет запущенный сервер.

Для такой же сборки локально на Windows, Linux или macOS: go run ./scripts/release.go v0.1.0. Для сборки без тега используйте go run ./scripts/release.go snapshot. Запускайте из корня репозитория. Результат сохраняется в dist/release/; папка создаётся скриптом и не должна существовать перед запуском. Перед повторной сборкой переместите предыдущую папку с результатами. Временные бинарники создаются в системном temp, а не в корне проекта.

Запуск в контейнерах

Нужны Docker Engine и Docker Compose v2. Из корня проекта один раз скопируйте config.container.example.yaml в config.container.yaml и добавьте targets по примерам из config.example.yaml. На PowerShell используйте Copy-Item вместо cp.

cp config.container.example.yaml config.container.yaml
# Отредактируйте targets и server.base_url в config.container.yaml.
docker compose up -d --build
docker compose run --rm sync

up запускает serve и enrich; sync запускается отдельно по имени сервиса. UI/API/RSS доступны на localhost:8080. Внутри контейнера server.listen должен оставаться 0.0.0.0:8080. Порт хоста привязан к 127.0.0.1, чтобы использовать reverse proxy на этом же сервере. Для другого порта задайте JOBWIRE_PORT перед запуском Compose; для другого конфига — JOBWIRE_CONFIG. При смене публичного URL обновите также server.base_url.

Образ собирается из исходников в два этапа; в финальном образе находятся бинарник, CA certificates для HTTPS и пустой пример конфига. Процесс работает с UID/GID 10001:10001. .dockerignore разрешает только входные файлы сборки: локальные конфиги и БД в образ не попадают.

Все три команды используют /data/jobwire.db через JOBWIRE_DB, заданный в образе. Compose хранит /data в общем named volume jobwire_jobwire-data, включая WAL и очередь enrichment. Это отдельная БД: существующая база из каталога данных пользователя автоматически в volume не копируется. Volume сохраняется после перезапуска, пересборки и docker compose down. docker compose down -v удаляет volume вместе с БД. Для bind mount вместо named volume каталог должен быть доступен на запись UID/GID 10001:10001.

docker compose logs -f serve enrich
docker compose stop
# После обновления исходников:
docker compose up -d --build

Discovery планируется снаружи контейнеров. Например, cron на Ubuntu каждые 30 минут (замените /opt/jobwire на путь checkout; пользователь cron должен иметь доступ к Docker):

*/30 * * * * cd /opt/jobwire && flock -n /opt/jobwire/.cache/container-sync.lock docker compose run --rm sync

Перед включением этой записи создайте каталог /opt/jobwire/.cache командой mkdir -p. Lock предотвращает наложение запусков этого cron; ручной sync не запускайте параллельно с ним. Для остановки sync при обновлении дождитесь его завершения и временно отключите cron. Перед миграцией схемы остановите все три процесса и сделайте backup SQLite по инструкции ниже.

Для запуска без Compose:

docker build -t jobwire:local .
docker run --rm -p 127.0.0.1:8080:8080 --mount source=jobwire-data,target=/data jobwire:local

Этот пример запускает serve с пустыми sources из образа; для своих targets смонтируйте конфиг в /etc/jobwire/config.yaml read-only. Схемы хранения volumes и запуска сервисов по profiles описаны в документации Docker и Compose profiles.

Deployment

reverse proxy → jobwire serve → HTML / API / RSS
                       ↘
                        SQLite ← jobwire sync (timer)
                           ↕
                       jobwire enrich (service)

Рекомендуемый layout:

/usr/local/bin/jobwire
/etc/jobwire/config.yaml
/var/lib/jobwire/jobwire.db

Systemd units задают XDG_DATA_HOME=/var/lib, поэтому default database — /var/lib/jobwire/jobwire.db. Явный путь в YAML, JOBWIRE_DB или --db сохраняет свой приоритет. Для существующей установки с /var/lib/jobwire/jobs.db оставьте этот путь в конфиге либо перенесите БД по инструкции выше. Используйте отдельного пользователя jobwire с правом записи в /var/lib/jobwire, listen 127.0.0.1:8080 и публичный base_url https://jobs.yarikus.com. Примеры unit files находятся в deploy/. После подготовки пользователя, папок, binary и config:

sudo systemctl daemon-reload
sudo systemctl enable --now jobwire.service jobwire-enrich.service jobwire-sync.timer
journalctl -u jobwire-sync.service -u jobwire-enrich.service

Sync timer работает раз в 30 минут. Внутри приложения нет scheduler для discovery. Enrichment service самостоятельно ожидает очередь и сроки retry. Не запускайте несколько независимых sync одного target одновременно.

Structured discovery logs содержат provider, target, fetched, inserted, updated, deactivated, enqueued, duration, error. Во время загрузки выводится progress раз в 15 секунд. Enrichment logs содержат provider, target, job_id, attempt, status, duration, error.

SQLite использует WAL, foreign keys и busy timeout 10 секунд. Для backup работающей БД используйте SQLite online backup; не копируйте только .db при активном WAL. Перед обновлением схемы проще остановить все процессы и сделать backup. TLS/reverse proxy находятся вне scope, authentication отсутствует.

Разработка и проверки

gofmt -w cmd internal migrations scripts
go test ./...
go vet ./...
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o bin/jobwire-linux-amd64 ./cmd/jobwire

Тесты не обращаются к внешним API. Используются временные SQLite databases и mock HTTP servers.

Покрыты: миграция заполненной старой БД, metadata upsert/target identity, lifecycle, rollback, nullable descriptions, публикация до enrichment, queue priority/retries/TTL, budget, bounded concurrency, lease recovery, конкурирующие claims, late responses, provider parsing/pagination, отсутствие Workday detail requests в discovery, 429/Retry-After и API/HTML/RSS.

cmd/jobwire/
internal/
  config/
  jobs/
  providers/{amazon,greenhouse,lever,workday}/
  discovery/
  enrichment/
  storage/
  httpserver/templates/
migrations/
deploy/

Provider реализует List(ctx, target) → []jobs.Stub. Необязательный DetailProvider добавляет GetDetail(ctx, target, ref) → jobs.Detail. Provider не знает о SQLite, очереди, API или пользователе.

Career agent работает только через HTTP API: metadata filtering → shortlist → detail evaluation. On-demand enrichment endpoint, AI ranking, application tracking, agent skills, дополнительные ATS и federation в этот MVP не входят. Локальный ID не является глобальным идентификатором.

Предложение для будущего обмена между экземплярами: JOBWIRE Federation Protocol — Draft 0.1. Документ описывает metadata sync, origin identity, отдельную загрузку details и необязательный trusted relay; протокол пока не реализован.